diff --git a/.gitignore b/.gitignore index 45138eb..a5642e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bin/ graphify-out/ dist/ +Tui-design-ins/ \ No newline at end of file diff --git a/README.md b/README.md index ef8a2b8..d596592 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,211 @@ -# atlas-cli - -A headless Atlas for servers: a Go binary that signs in like the Atlas desktop -app, binds a folder to an organisation and a cloud Project, and gives every -coding agent run in that folder **session capture** and **shared memory**, -byte-compatible with the desktop's on-disk formats. The agents keep their own -UIs: Atlas plugs into their hooks (ADR-0013). +

+ atlas: shared memory and session capture for coding agents +

+ +

atlas

+ +

+ Shared memory and session capture for coding agents, from the terminal.
+ Claude Code, Codex, Cursor, Copilot, OpenCode, Droid, Antigravity and Pi keep their own UIs;
+ Atlas records every session and gives every agent the same memory of the project. +

+ +

+ Latest release + Pure Go + macOS and Linux + Apache-2.0 +

+ +

+ Install · + Quick start · + Shared memory · + Session capture · + Agents · + Commands +

+ +--- + +`atlas` is the headless Atlas: one Go binary that signs in like the Atlas +desktop app, binds a folder to an organisation and a cloud Project, and plugs +into the hooks of the agents you already use. It writes the same files the app +writes, byte for byte, so a folder works the same whether it is open in the app, +in a terminal on a server, or both. ## Install -Linux (amd64, arm64) and macOS (Apple silicon, Intel): +macOS (Apple silicon, Intel) and Linux (amd64, arm64): ```sh curl -fsSL https://github.com/tryatlas/atlas-cli/releases/latest/download/install.sh | sh ``` -It verifies the archive's sha256 and installs `atlas` to `~/.local/bin` -(`ATLAS_INSTALL_DIR` to change it, `ATLAS_VERSION=v1.2.3` to pin). From -source: `make build` (pure Go, no cgo). +The installer verifies the archive's sha256 and puts `atlas` in `~/.local/bin` +(`ATLAS_INSTALL_DIR` changes it, `ATLAS_VERSION=v0.4.0` pins it). Later, +`atlas update` does the same in place. From source: `make build` (pure Go, no +cgo). -## Use +## Quick start -``` -atlas login # device grant against the Atlas auth server +```sh +atlas login # sign in (the same account as the desktop app) cd ~/work/backend -atlas init # org + cloud Project, writes .atlas/ (--local: no cloud) -atlas agents add claude-code # the agents you use here (atlas agents list shows all eight) -atlas agents add codex cursor -claude # …or codex, cursor-agent, opencode: work as usual -atlas status # sessions captured, commits linked, sync +atlas init # organisation → Project → agents → background sync +claude # …or codex, cursor-agent, opencode, copilot: work as usual +atlas status # what was captured, what synced, what agents remember ``` -`atlas agents add` writes two things into the agent's own config in the -Project: its **hook**, which runs `atlas hooks ` at session -start, each prompt, each turn end and session end; and the **`atlas_memory` -MCP server**, so the agent can read and write shared memory. It also installs a -git `post-commit` hook that links sessions to the commits that carry their -work. Nothing is wrapped and no environment variable changes; remove it all with -`atlas agents remove `. - -Agents run their project hooks only for a project you trust: Codex, Copilot -and Pi each ask once in their own UI. Keep `atlas` current on PATH, including -`~/.local/bin`, which some agents put first. - -At each turn end the hook reads the agent's transcript from where the turn -started, diffs `git status`, and records the prompt, the reply, the tool calls -with the files they wrote, token usage and the model. The hook writes to the -store itself, or hands the turn to a running `atlas serve`, or spools it when -the desktop app has the folder open. A hook never fails the agent: errors go to -`.atlas/cli-hooks.log` and `atlas status`. +`atlas init` is a guided flow on a terminal: + +| step | asks | +|---|---| +| 1 · Organisation | which organisation the folder belongs to (skipped when you have one) | +| 2 · Project | the cloud Project: the one matching this repository first, the others, or a new one | +| 3 · Agents | which agents found on this machine to hook, with an **All agents** row | +| 4 · Background sync | keep a listener running so every event reaches the cloud at once | +| 5 · Memory search | download the local search model (87 MB, once) when it is missing | + +`--local` binds without a cloud. In scripts, flags answer instead: `--org`, +`--project | --create`, `--agents claude-code,codex | --all-agents`, +`--sync on|off`. + +`atlas settings` changes any of it later on one screen: account, capture and +memory, each agent, background sync, the local model, updates. + +## Shared memory + +Every agent in the project reads and writes one memory, through the +`atlas_memory` MCP server that `atlas agents add` registers in the agent's own +config. Nothing is ever injected into a prompt: agents pull what they need. + +| tool | what an agent gets | +|---|---| +| `memory_briefing` | at session start: the active **plan**, recent file changes, decisions, facts, failures, architecture, conventions, and the previous session's handoff | +| `memory_search` | memories ranked by **meaning and words**, plus the project's **documents** (CLAUDE.md, AGENTS.md, notes, captured sessions) | +| `memory_remember` | save a decision, fact, failure, architecture note or preference; near-duplicates merge | +| `memory_changes` | what other sessions recorded since this one last looked | +| `memory_get` · `memory_history` · `memory_why` | one memory, its earlier wordings, and the sessions behind a file or commit | +| `memory_forget` · `memory_feedback` · `memory_list` | remove, rate and list | + +- **Search by meaning.** A small local model (MiniLM, the one the app uses) runs + in pure Go on your machine; nothing is sent anywhere. Ask "how do we rotate + tokens" and find "signing keys rotate every week". Without the model, search + falls back to words. +- **Plans are captured, not typed.** When an agent writes its to-do list + (`TodoWrite`, `update_plan`, `todowrite`, Claude's tasks and plan mode, …), + it becomes the project's plan, and the next session, or another agent, sees + it in its briefing. +- **Memories write themselves.** Decisions an agent states in its reply, and + what the extractor distils from long sessions, land as memories; at session + end a handoff note tells the next session where things stand. +- **One record with the app.** `.atlas/memory/` holds the app's exact files: + `memory.sqlite` (with its vectors), `corpus.sqlite`, `memory.snapshot.sqlite` + (a daily backup, restored automatically if the record is damaged) and + `extract-state/`. + +```sh +atlas memory browse # entries by kind, search, history +atlas memory index # embed every memory, index the project's documents +``` + +## Session capture + +Capture happens per event, as the agent works: + +1. **The prompt** opens the turn the moment you submit it. +2. **Each tool call** lands as the agent's post-tool hook fires, with the files it + wrote, including files a shell command changed. +3. **The reply** closes the turn at the agent's stop hook, with token usage and + the model, read from the transcript the agent names. +4. **Commits** an agent makes get their checkpoint: a git `post-commit` hook + links each commit to the session and turn that wrote it. + +Every event reaches the cloud Project within seconds: the hook writes the store +and syncs, or hands the event to `atlas serve`, or spools it while the desktop +app has the folder open. A hook never fails the agent; problems go to +`atlas status`. + +```sh +atlas sync on # a background listener your agents' hooks keep alive +atlas serve # the same in the foreground, with a live feed of hook events +``` + +## Agents + +`atlas agents add ` writes the hook and the memory server into the +agent's own config in the project; `atlas agents remove` takes them out. +Nothing is wrapped and no environment variable changes. | agent | hook written to | memory server written to | |---|---|---| -| `claude-code` Claude Code | `.claude/settings.local.json` | `.mcp.json` (+ approved in the local settings) | -| `codex` Codex | `.codex/hooks.json`: the first interactive `codex` here asks "Hooks need review"; choose **Trust all and continue** (`codex exec` cannot ask) | `.codex/config.toml` (trusted projects) | -| `cursor` Cursor CLI and IDE | `.cursor/hooks.json` (`cursor-agent -p` fires no turn hooks; such a session is captured at its end) | `.cursor/mcp.json` (Cursor asks to approve it once) | -| `copilot` GitHub Copilot CLI | `.github/hooks/atlas.json`: loaded once the folder is trusted (the first interactive `copilot` asks; `copilot -p` does not) | `.github/mcp.json` (same trust) | +| `claude-code` Claude Code | `.claude/settings.local.json` | `.mcp.json`, approved in the local settings | +| `codex` Codex | `.codex/hooks.json`: choose **Trust all and continue** once | `.codex/config.toml` | +| `cursor` Cursor CLI and IDE | `.cursor/hooks.json` | `.cursor/mcp.json`, approved once in Cursor | +| `copilot` GitHub Copilot CLI | `.github/hooks/atlas.json`, once the folder is trusted | `.github/mcp.json` | | `opencode` OpenCode | `.opencode/plugins/atlas.ts` | `opencode.json` | | `droid` Factory Droid | `.factory/settings.json` | `.factory/mcp.json` | -| `antigravity` Antigravity (`agy`) | `.agents/hooks.json` (run `agy` with an absolute `--add-dir`) | `.agents/mcp_config.json` | -| `pi` Pi | `.pi/extensions/atlas/index.ts`: loaded once the project is trusted in Pi (`pi --approve` for one run) | `.pi/mcp.json` (same trust) | +| `antigravity` Antigravity (`agy`) | `.agents/hooks.json` | `.agents/mcp_config.json` | +| `pi` Pi | `.pi/extensions/atlas/index.ts`, once the project is trusted | `.pi/mcp.json` | + +Keep `atlas` current on `PATH`, including `~/.local/bin`, which some agents put +first. + +## Commands + +Every command draws its own screen on a terminal (the Atlas palette: monochrome, +colour only for status) and prints plain text to pipes and scripts. | command | what | |---|---| -| `atlas agents add \| remove \| list \| sessions` | install or remove the hook and memory entry per agent; list agents and the sessions seen | -| `atlas serve [--daemon \| --stop]` | the Project's listener: memory server, sync drain, receiver of hook turns while it holds the store | -| `atlas status` | sign-in, Project, gates, capture health, commits linked, sync, memory, agents and hook errors | -| `atlas memory browse \| list \| verify \| export \| import` | shared memory (this machine only) | -| `atlas capture sync \| retry \| link` | cloud sync now, retry failed rows, link sessions to commits by hand | -| `atlas mcp-bridge --project --agent ` | the stdio MCP entry the agents' configs hold (serves memory with or without `atlas serve`) | +| `atlas` | the overview: logo, commands by group | +| `atlas login` · `logout` · `whoami` | sign in with the device grant; the session file is the only secret kept (mode 0600) | +| `atlas org list \| use \| create` | organisations | +| `atlas init` | bind the folder, hook the agents, choose background sync and memory search | +| `atlas settings` | everything on one screen | +| `atlas status` | sign-in, Project, capture health, commits linked, sync, memory, agents | +| `atlas agents add \| remove \| list \| sessions` | the hooks and memory entry per agent; the sessions seen | +| `atlas sync [on \| off]` | background sync | +| `atlas serve` | the Project's listener: memory server, sync drain, receiver of hook events | +| `atlas memory browse \| list \| index \| verify \| export \| import` | shared memory | +| `atlas memory enable \| disable` | the shared-memory gate for this repository | +| `atlas capture enable \| disable \| sync \| retry \| link` | the capture gate; sync now, retry failed rows, link commits | +| `atlas update [--check] [--version vX.Y.Z]` | replace this binary with a release, checksum verified | + +## What lives where + +| path | what | +|---|---| +| `.atlas/` | the app's per-project files only: `sessions.db`, `blobs/`, `memory/`, `project.json` | +| `~/.local/state/atlas-cli/projects//` | the CLI's own: hook state, `hooks.log`, the listener's run file, preferences | +| `~/Library/Application Support/dev.atlas.ide/models/` | the local search model, shared with the app (`~/.local/share/…` on Linux) | -The ACP path of v0.1 (`atlas run ` with Atlas's own chat screen, the -registry installs) is parked under the hidden `atlas acp` group (ADR-0013 §8). +## Development -## Where things are +```sh +make build # ./bin/atlas +make test # go test ./... +make lint # golangci-lint +``` -| path | what | +| doc | what | |---|---| -| `docs/atlas-reference/README.md` | index of the hand-off folder copied from the desktop repo: read first | -| `docs/atlas-reference/SPEC.md` | the specification; §1.2 holds the locked decisions D1–D20 | -| `docs/atlas-reference/evidence-*.md` | desktop-side facts with `file:line` citations into the desktop repo | -| `docs/research/entire-cli.md` | how the Entire CLI captures agents through hooks; the per-agent facts this CLI's integrations follow | -| `docs/adr/` | this repo's own ADRs; `0013` is the hook-based capture of native agent UIs and supersedes `0009`/`0012` as the product | -| `docs/RELEASE.md` | how to release, and the acceptance checklists | | `docs/progress.md` | what was built, what was verified live, what was not | +| `docs/adr/` | decisions; `0013` is capture through agents' hooks | +| `docs/research/entire-cli.md` | the per-agent hook facts the integrations follow | +| `docs/atlas-reference/` | the desktop app's specification and file formats | +| `docs/RELEASE.md` | how to release, and the acceptance checklists | + +Ground rules: a hook reads only the transcript file the agent's own payload +names, from the turn's offset, while the hook runs, and never scans an agent's +folders; memory is never injected into prompts; an `Authorization` header is +never logged; every stored text passes the redactor; no secret persists except +the session token file. + +The ACP path of v0.1 (`atlas run `) is parked under the hidden +`atlas acp` group. + +## License -Ground rules that are not negotiable: a hook reads only the transcript file -the agent's own payload names, from the turn's offset, while the hook runs -(never a scan of an agent's directories); never inject memory into prompts; -never log an `Authorization` header; every stored text passes the redactor -first; and no secret persists except the session token file (mode 0600). +[Apache-2.0](LICENSE) diff --git a/cmd/atlas/agents_cmds.go b/cmd/atlas/agents_cmds.go index 31c69a0..fa90d05 100644 --- a/cmd/atlas/agents_cmds.go +++ b/cmd/atlas/agents_cmds.go @@ -16,6 +16,7 @@ import ( "github.com/tryatlas/atlas-cli/internal/hooks" "github.com/tryatlas/atlas-cli/internal/hooks/mcpconfig" "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui" ) // atlas agents: the agents that run in their own UIs and report to Atlas @@ -85,13 +86,34 @@ func runAgentsAdd(cmd *cobra.Command, args []string) error { if err != nil { return err } - if changed, err := hooks.InstallGitHooks(ctx, p.Root); err != nil { + w := out + if interactive() { + tui.Header(out, "agents", "add") + fmt.Fprintln(out) + w = agentLines(out) + } + if err := installAgents(ctx, w, p.Root, agents, force, noMem); err != nil { + return err + } + if interactive() { + fmt.Fprintln(out) + tui.Note(out, "Use the agents as usual in "+p.Root+"; sessions are captured as they run. atlas status shows them.") + } else { + fmt.Fprintf(out, "Use the agents as usual in %s; sessions are captured as they run. atlas status shows them.\n", p.Root) + } + return nil +} + +// installAgents writes the git hook, then each agent's hook and memory entry, +// one line per agent. +func installAgents(ctx context.Context, out io.Writer, root string, agents []hooks.Agent, force, noMem bool) error { + if changed, err := hooks.InstallGitHooks(ctx, root); err != nil { return fmt.Errorf("git hooks: %w", err) } else if changed { fmt.Fprintln(out, "Installed the git post-commit hook (links sessions to commits).") } for _, a := range agents { - changed, err := a.Install(p.Root, force) + changed, err := a.Install(root, force) if err != nil { return fmt.Errorf("%s: %w", a.ID(), err) } @@ -101,12 +123,12 @@ func runAgentsAdd(cmd *cobra.Command, args []string) error { } line := fmt.Sprintf("%s: hook %s", a.Name(), what) if w, ok := mcpconfig.For(a.ID()); ok && !noMem { - mchanged, err := w.Write(p.Root) + mchanged, err := w.Write(root) if err != nil { return fmt.Errorf("%s memory config: %w", a.ID(), err) } if mchanged { - line += ", memory server added to " + strings.TrimPrefix(w.Path(p.Root), p.Root+"/") + line += ", memory server added to " + strings.TrimPrefix(w.Path(root), root+"/") } else { line += ", memory server present" } @@ -121,7 +143,6 @@ func runAgentsAdd(cmd *cobra.Command, args []string) error { fmt.Fprintf(out, " note: %s\n", n.AddNote()) } } - fmt.Fprintf(out, "Use the agents as usual in %s; sessions are captured as they run. atlas status shows them.\n", p.Root) return nil } @@ -137,12 +158,38 @@ func runAgentsRemove(cmd *cobra.Command, args []string) error { if err != nil { return err } + if interactive() { + tui.Header(out, "agents", "remove") + fmt.Fprintln(out) + out = agentLines(out) + } + return removeAgents(ctx, out, p.Root, agents) +} + +// agentLines styles installAgents' and removeAgents' lines on a terminal: +// "Claude Code: hook installed, …" becomes "✓ Claude Code hook installed, …", +// notes go faint under it. +func agentLines(w io.Writer) io.Writer { + return tui.Lines(w, func(l string) string { + if n, ok := strings.CutPrefix(l, " note: "); ok { + return " " + tui.Default.Faint.Render("↳ "+n) + } + if name, rest, ok := strings.Cut(l, ": "); ok && !strings.Contains(name, " the ") { + return tui.Default.Success.Render("✓") + " " + tui.Default.Bold.Render(fmt.Sprintf("%-20s", name)) + " " + tui.Default.Faint.Render(rest) + } + return tui.Default.Success.Render("✓") + " " + l + }) +} + +// removeAgents takes the Atlas entries out of each agent's config, and the +// git hook once no agent is left. +func removeAgents(ctx context.Context, out io.Writer, root string, agents []hooks.Agent) error { for _, a := range agents { - if err := a.Uninstall(p.Root); err != nil { + if err := a.Uninstall(root); err != nil { return fmt.Errorf("%s: %w", a.ID(), err) } if w, ok := mcpconfig.For(a.ID()); ok { - if err := w.Remove(p.Root); err != nil { + if err := w.Remove(root); err != nil { return fmt.Errorf("%s memory config: %w", a.ID(), err) } } @@ -150,12 +197,12 @@ func runAgentsRemove(cmd *cobra.Command, args []string) error { } left := false for _, a := range hooks.All() { - if a.State(p.Root) != hooks.Absent { + if a.State(root) != hooks.Absent { left = true } } if !left { - if err := hooks.UninstallGitHooks(ctx, p.Root); err != nil { + if err := hooks.UninstallGitHooks(ctx, root); err != nil { return err } fmt.Fprintln(out, "No agent left: the git post-commit hook was removed too.") @@ -169,6 +216,9 @@ func runAgentsList(cmd *cobra.Command, _ []string) error { if p, err := agentRoot(ctx); err == nil { root = p.Root } + if interactive() { + return agentsTable(cmd.OutOrStdout(), root) + } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) fmt.Fprintln(w, "AGENT\tNAME\tBINARY\tHOOK\tMEMORY") for _, a := range hooks.All() { @@ -196,6 +246,44 @@ func runAgentsList(cmd *cobra.Command, _ []string) error { return nil } +// agentsTable is atlas agents list on a terminal. +func agentsTable(out io.Writer, root string) error { + tui.Header(out, "agents") + fmt.Fprintln(out) + state := func(st hooks.InstallState) string { + switch st { + case hooks.Current: + return tui.Dot(tui.Good, "installed") + case hooks.Outdated: + return tui.Dot(tui.Warn, "outdated") + } + return tui.Dot(tui.Idle, "off") + } + var rows [][]string + for _, a := range hooks.All() { + bin := tui.Default.Faint.Render("not on PATH") + if _, err := exec.LookPath(a.Binary()); err == nil { + bin = a.Binary() + } + hook, mem := tui.Default.Faint.Render("-"), tui.Default.Faint.Render("-") + if root != "" { + hook = state(a.State(root)) + if mw, ok := mcpconfig.For(a.ID()); ok { + mem = state(mw.State(root)) + } + } + rows = append(rows, []string{a.Name(), tui.Default.Faint.Render(a.ID()), bin, hook, mem}) + } + fmt.Fprint(out, tui.Table([]string{"agent", "id", "binary", "hook", "memory"}, rows)) + fmt.Fprintln(out) + if root == "" { + tui.Note(out, "Not in an Atlas Project: run atlas init, then atlas agents add .") + } else { + tui.Note(out, "atlas agents add · atlas agents add --all · atlas settings") + } + return nil +} + func runAgentsSessions(cmd *cobra.Command, _ []string) error { p, err := agentRoot(cmd.Context()) if err != nil { @@ -209,6 +297,28 @@ func runAgentsSessions(cmd *cobra.Command, _ []string) error { fmt.Fprintln(cmd.OutOrStdout(), "No session seen by the hooks yet.") return nil } + if interactive() { + out := cmd.OutOrStdout() + tui.Header(out, "agents", "sessions") + fmt.Fprintln(out) + var rows [][]string + for _, s := range states { + st := tui.Dot(tui.Good, "idle") + switch { + case s.Ended: + st = tui.Dot(tui.Idle, "ended") + case s.InTurn: + st = tui.Dot(tui.Warn, "in a turn") + } + resume := "" + if a, ok := hooks.Get(s.Agent); ok { + resume = a.ResumeCommand(s.SessionID) + } + rows = append(rows, []string{s.Agent, tui.Fit(s.SessionID, 28), fmt.Sprint(s.Turn), st, ago(s.UpdatedAt), tui.Default.Faint.Render(resume)}) + } + fmt.Fprint(out, tui.Table([]string{"agent", "session", "turns", "state", "last", "resume"}, rows)) + return nil + } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) fmt.Fprintln(w, "AGENT\tSESSION\tTURNS\tSTATE\tLAST\tRESUME") for _, s := range states { diff --git a/cmd/atlas/auth_cmds.go b/cmd/atlas/auth_cmds.go index 720ece4..d50fc8f 100644 --- a/cmd/atlas/auth_cmds.go +++ b/cmd/atlas/auth_cmds.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/tryatlas/atlas-cli/internal/auth" + "github.com/tryatlas/atlas-cli/internal/tui" ) // newAuth is replaced in tests. @@ -26,6 +27,29 @@ func runLogin(cmd *cobra.Command, _ []string) error { } noBrowser, _ := cmd.Flags().GetBool("no-browser") out := cmd.OutOrStdout() + if interactive() { + var s *auth.Session + err := tui.DeviceLogin(func(prompt func(tui.DevicePrompt)) (string, error) { + var err error + s, err = a.Login(cmd.Context(), func(p auth.Prompt) { + opened := !noBrowser && canOpenBrowser() && openBrowser(p.VerificationURI) == nil + prompt(tui.DevicePrompt{URL: p.VerificationURI, Code: p.UserCode, Expires: p.ExpiresAt, Opened: opened}) + }) + if err != nil { + return "", err + } + if s.Identity != nil { + return fmt.Sprintf("%s <%s>", s.Identity.Name, s.Identity.Email), nil + } + return "you", nil + }) + if err != nil { + return err + } + fmt.Fprintln(out) + printIdentity(out, s, a.Store.Path()) + return nil + } s, err := a.Login(cmd.Context(), func(p auth.Prompt) { fmt.Fprintf(out, "To sign in, open %s and enter the code:\n\n %s\n\n", p.VerificationURI, p.UserCode) if !noBrowser && canOpenBrowser() { @@ -48,20 +72,18 @@ func runLogout(cmd *cobra.Command, _ []string) error { if err != nil { return err } - confirmed, err := a.Logout(cmd.Context()) - if errors.Is(err, auth.ErrSignedOut) { - fmt.Fprintln(cmd.OutOrStdout(), "Not signed in.") - return nil - } - if err != nil { - return err - } - if confirmed { - fmt.Fprintln(cmd.OutOrStdout(), "Signed out.") - } else { - fmt.Fprintln(cmd.OutOrStdout(), "Signed out on this machine. Atlas did not confirm the server session ended; it expires on its own.") - } - return nil + return tui.Work(cmd.OutOrStdout(), "Signing out", func(tui.StepFunc) (string, error) { + confirmed, err := a.Logout(cmd.Context()) + switch { + case errors.Is(err, auth.ErrSignedOut): + return "Not signed in.", nil + case err != nil: + return "", err + case confirmed: + return "Signed out.", nil + } + return "Signed out on this machine. Atlas did not confirm the server session ended; it expires on its own.", nil + }) } func runWhoami(cmd *cobra.Command, _ []string) error { @@ -75,7 +97,12 @@ func runWhoami(cmd *cobra.Command, _ []string) error { return err } if refresh { - r, rerr := a.Refresh(cmd.Context()) + var r *auth.Session + var rerr error + _ = tui.Work(io.Discard, "Refreshing your profile", func(tui.StepFunc) (string, error) { + r, rerr = a.Refresh(cmd.Context()) + return "", nil + }) if errors.Is(rerr, auth.ErrSignedOut) { return rerr } @@ -95,7 +122,11 @@ func runOrgList(cmd *cobra.Command, _ []string) error { if err != nil { return err } - s, err := a.Refresh(cmd.Context()) + var s *auth.Session + _ = tui.Work(io.Discard, "Fetching your organisations", func(tui.StepFunc) (string, error) { + s, err = a.Refresh(cmd.Context()) + return "", nil + }) if errors.Is(err, auth.ErrSignedOut) { return err } @@ -124,13 +155,13 @@ func runOrgUse(cmd *cobra.Command, args []string) error { } out := cmd.OutOrStdout() if id == "" { - fmt.Fprintln(out, "No organisation pinned.") + tui.Done(out, "No organisation pinned.") return nil } if s.ActiveOrgID() != id { fmt.Fprintf(cmd.ErrOrStderr(), "atlas: %s is not in your organisation list; pinned anyway. Run atlas org list to refresh it.\n", id) } - fmt.Fprintf(out, "Active organisation: %s\n", orgLabel(s, id)) + tui.Done(out, "Active organisation: "+orgLabel(s, id)) return nil } @@ -152,26 +183,30 @@ func runOrgCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot make a handle from %q; pass --slug", name) } ctx := cmd.Context() - free, err := a.Client.CheckSlug(ctx, s.SessionToken, slug) - if err != nil { - return err - } - if !free { - return fmt.Errorf("the handle %q is taken; pass another with --slug", slug) - } - created, err := a.Client.CreateOrg(ctx, s.SessionToken, name, slug) - if err != nil { - var f *auth.Failure - if errors.As(err, &f) && f.Kind == auth.Denied { - return errors.New("couldn't create it: that organisation name or handle may already be taken") + return tui.Work(cmd.OutOrStdout(), "Creating "+name, func(step tui.StepFunc) (string, error) { + step("checking the handle " + slug) + free, err := a.Client.CheckSlug(ctx, s.SessionToken, slug) + if err != nil { + return "", err } - return err - } - if _, err := a.Refresh(ctx); err != nil && !errors.Is(err, auth.ErrSignedOut) { - fmt.Fprintln(cmd.ErrOrStderr(), "atlas: created, but could not refresh the list:", err) - } - fmt.Fprintf(cmd.OutOrStdout(), "Created %s (%s). Run atlas org use %s to make it active.\n", created.Name, created.ID, created.ID) - return nil + if !free { + return "", fmt.Errorf("the handle %q is taken; pass another with --slug", slug) + } + step("creating the organisation") + created, err := a.Client.CreateOrg(ctx, s.SessionToken, name, slug) + if err != nil { + var f *auth.Failure + if errors.As(err, &f) && f.Kind == auth.Denied { + return "", errors.New("couldn't create it: that organisation name or handle may already be taken") + } + return "", err + } + step("refreshing your organisations") + if _, err := a.Refresh(ctx); err != nil && !errors.Is(err, auth.ErrSignedOut) { + fmt.Fprintln(cmd.ErrOrStderr(), "atlas: created, but could not refresh the list:", err) + } + return fmt.Sprintf("Created %s (%s). Run atlas org use %s to make it active.", created.Name, created.ID, created.ID), nil + }) } var slugJunk = regexp.MustCompile(`[^a-z0-9]+`) @@ -184,6 +219,21 @@ func printIdentity(w io.Writer, s *auth.Session, path string) { if s == nil { return } + if interactive() { + tui.Header(w, "account") + tui.Section(w, "Signed in") + if id := s.Identity; id != nil { + tui.Field(w, "Name", tui.Default.Bold.Render(id.Name)) + tui.Field(w, "Email", id.Email) + tui.Field(w, "User id", tui.Default.Faint.Render(id.ID)) + } else { + tui.Field(w, "Profile", "not fetched yet (atlas whoami --refresh)") + } + printOrgs(w, s) + tui.Section(w, "On this machine") + tui.Field(w, "Session file", tui.Default.Faint.Render(path)) + return + } if id := s.Identity; id != nil { fmt.Fprintf(w, "Signed in as %s <%s> (%s)\n", id.Name, id.Email, id.ID) } else { @@ -195,6 +245,23 @@ func printIdentity(w io.Writer, s *auth.Session, path string) { func printOrgs(w io.Writer, s *auth.Session) { id := s.Identity + if interactive() && id != nil && id.Orgs != nil && len(*id.Orgs) > 0 { + tui.Section(w, "Organisations") + var rows [][]string + for _, o := range *id.Orgs { + role := "" + if o.Role != nil { + role = string(*o.Role) + } + state := tui.Default.Off.Render("○") + if o.ID == s.ActiveOrgID() { + state = tui.Default.Success.Render("●") + " active" + } + rows = append(rows, []string{o.Name, tui.Default.Faint.Render(o.ID), role, state}) + } + fmt.Fprint(w, tui.Table([]string{"name", "id", "role", ""}, rows)) + return + } switch { case id == nil || id.Orgs == nil: fmt.Fprintln(w, "Organisations: not known yet") diff --git a/cmd/atlas/capture_cmds.go b/cmd/atlas/capture_cmds.go index 6a18ffb..ed77f1a 100644 --- a/cmd/atlas/capture_cmds.go +++ b/cmd/atlas/capture_cmds.go @@ -10,6 +10,7 @@ import ( gitlink "github.com/tryatlas/atlas-cli/internal/capture/git" "github.com/tryatlas/atlas-cli/internal/capture/store" "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui" ) // runCaptureLink walks new commits and links them to captured sessions. @@ -35,14 +36,16 @@ func runCaptureLink(cmd *cobra.Command, _ []string) error { return err } defer st.Close() - res, err := gitlink.Link(cmd.Context(), st, p.Root, now) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Checked %d commit(s); %d checkpoint(s) written.\n", res.Commits, res.Checkpoints) - if rw := res.Rewrites; rw.Relinked+rw.Orphaned+rw.Recovered+rw.Failed > 0 { - fmt.Fprintf(cmd.OutOrStdout(), "Rewritten history: %d relinked, %d orphaned, %d recovered, %d not checked.\n", - rw.Relinked, rw.Orphaned, rw.Recovered, rw.Failed) - } - return nil + return tui.Work(cmd.OutOrStdout(), "Linking sessions to commits", func(tui.StepFunc) (string, error) { + res, err := gitlink.Link(cmd.Context(), st, p.Root, now) + if err != nil { + return "", err + } + msg := fmt.Sprintf("Checked %d commit(s); %d checkpoint(s) written.", res.Commits, res.Checkpoints) + if rw := res.Rewrites; rw.Relinked+rw.Orphaned+rw.Recovered+rw.Failed > 0 { + msg += fmt.Sprintf("\nRewritten history: %d relinked, %d orphaned, %d recovered, %d not checked.", + rw.Relinked, rw.Orphaned, rw.Recovered, rw.Failed) + } + return msg, nil + }) } diff --git a/cmd/atlas/commands.go b/cmd/atlas/commands.go index 4265f9a..b9648b8 100644 --- a/cmd/atlas/commands.go +++ b/cmd/atlas/commands.go @@ -4,6 +4,8 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/tryatlas/atlas-cli/internal/tui" ) // notImplementedError is returned by every command that has not been built yet. @@ -26,6 +28,14 @@ func newRootCmd() *cobra.Command { SilenceErrors: true, } root.SetVersionTemplate("atlas {{.Version}}\n") + root.SetHelpFunc(styledHelp(root.HelpFunc())) + root.RunE = func(cmd *cobra.Command, _ []string) error { + if !interactive() { + return cmd.Help() + } + splash(cmd) + return nil + } root.AddCommand( newLoginCmd(), @@ -36,6 +46,9 @@ func newRootCmd() *cobra.Command { newMemoryCmd(), newCaptureCmd(), newServeCmd(), + newSyncCmd(), + newSettingsCmd(), + newUpdateCmd(), withRun(leaf("status", "Show login, organisation, Project, gates, capture health and agent integrations", cobra.NoArgs), runStatus), newAgentsCmd(), newHooksCmd(), @@ -45,6 +58,32 @@ func newRootCmd() *cobra.Command { return root } +// splashGroups orders the splash's command list. +var splashGroups = []struct { + title string + cmds []string +}{ + {"Start", []string{"login", "init", "status", "settings"}}, + {"Agents", []string{"agents", "sync"}}, + {"Memory and capture", []string{"memory", "capture"}}, + {"Account", []string{"whoami", "org", "logout", "update"}}, +} + +// splash is `atlas` alone on a terminal. +func splash(cmd *cobra.Command) { + groups := map[string][]tui.Command{} + var order []string + for _, g := range splashGroups { + order = append(order, g.title) + for _, name := range g.cmds { + if c, _, err := cmd.Find([]string{name}); err == nil && c != cmd { + groups[g.title] = append(groups[g.title], tui.Command{Name: name, Short: c.Short}) + } + } + } + tui.Splash(cmd.OutOrStdout(), version, groups, order) +} + // leaf builds a stub command with the given argument rule. func leaf(use, short string, args cobra.PositionalArgs) *cobra.Command { return &cobra.Command{Use: use, Short: short, Args: args, RunE: stub} @@ -95,6 +134,10 @@ func newInitCmd() *cobra.Command { c.Flags().String("visibility", "org", "who sees a new Project: org or restricted") c.Flags().Bool("local", false, "bind locally only; nothing is synced") c.Flags().Bool("yes", false, "do not prompt for confirmation") + c.Flags().StringSlice("agents", nil, "hook these agents (comma-separated ids, atlas agents list); skips the agent step") + c.Flags().Bool("all-agents", false, "hook every agent detected on this machine; skips the agent step") + c.Flags().String("sync", "", "background sync: on or off (atlas sync); skips the sync step") + c.MarkFlagsMutuallyExclusive("agents", "all-agents") c.MarkFlagsMutuallyExclusive("project", "create") c.MarkFlagsMutuallyExclusive("local", "project") c.MarkFlagsMutuallyExclusive("local", "create") @@ -107,6 +150,7 @@ func newMemoryCmd() *cobra.Command { withRun(leaf("disable", "Turn shared memory off for this repository", cobra.NoArgs), runMemoryGate(false)), withRun(leaf("verify", "Verify the memory revision hash chain", cobra.NoArgs), runMemoryVerify), withRun(leaf("browse", "Browse shared memory: entries by kind, search, history", cobra.NoArgs), runMemoryBrowse), + newMemoryIndexCmd(), newMemoryListCmd(), withRun(leaf("export ", "Export durable memories as an Agent Memory Repo (MEMORY.md and one file per kind)", cobra.ExactArgs(1)), runMemoryExport), withRun(leaf("import ", "Import Agent Memory Repo files as candidate memories", cobra.ExactArgs(1)), runMemoryImport), @@ -126,7 +170,7 @@ func newCaptureCmd() *cobra.Command { func newServeCmd() *cobra.Command { c := withRun(leaf("serve", "Run the Project's listener: memory server, sync drain, and the receiver of agent hooks", cobra.NoArgs), runServe) c.Flags().Bool("daemon", false, "start the listener detached") - c.Flags().Bool("stop", false, "stop a detached listener") + c.Flags().Bool("stop", false, "stop a detached listener (atlas sync off also keeps hooks from restarting it)") c.Flags().Duration("import-every", 0, "ACP import of native sessions this often (parked; 0: never)") _ = c.Flags().MarkHidden("import-every") c.MarkFlagsMutuallyExclusive("daemon", "stop") @@ -161,6 +205,7 @@ func newMemoryListCmd() *cobra.Command { c := withRun(leaf("list", "Print shared memory entries (for scripts; atlas memory browse on a terminal)", cobra.NoArgs), runMemoryList) c.Flags().String("kind", "", "only this kind: plan, file_changed, decision, fact, failure, architecture, preference") c.Flags().String("search", "", "search instead of listing") + c.Flags().Bool("all", false, "include candidates (unconfirmed memories), as the app shows them") return c } diff --git a/cmd/atlas/help_view.go b/cmd/atlas/help_view.go new file mode 100644 index 0000000..d72aa9e --- /dev/null +++ b/cmd/atlas/help_view.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/tryatlas/atlas-cli/internal/tui" +) + +// styledHelp is --help on a terminal: the wordmark and path, what the +// command does, then usage, subcommands and flags under upper-case labels. +// Pipes keep cobra's plain help. +func styledHelp(plain func(*cobra.Command, []string)) func(*cobra.Command, []string) { + return func(cmd *cobra.Command, args []string) { + if !interactive() { + plain(cmd, args) + return + } + w := cmd.OutOrStdout() + path := strings.Fields(cmd.CommandPath()) + tui.Header(w, path[1:]...) + fmt.Fprintln(w) + desc := cmd.Long + if desc == "" { + desc = cmd.Short + } + for _, l := range tui.Wrap(desc, min(tui.Width()-4, 96)) { + fmt.Fprintln(w, " "+l) + } + tui.Section(w, "Usage") + fmt.Fprintln(w, " "+tui.Default.Bold.Render(cmd.UseLine())) + var rows [][]string + for _, c := range cmd.Commands() { + if c.IsAvailableCommand() { + rows = append(rows, []string{c.Name(), tui.Default.Faint.Render(tui.Fit(c.Short, max(tui.Width()-30, 20)))}) + } + } + if len(rows) > 0 { + tui.Section(w, "Commands") + for _, r := range rows { + fmt.Fprintln(w, " "+tui.Default.Bold.Render(fmt.Sprintf("%-12s", r[0]))+" "+r[1]) + } + } + if f := cmd.LocalFlags().FlagUsages(); f != "" { + tui.Section(w, "Flags") + for _, l := range strings.Split(strings.TrimRight(f, "\n"), "\n") { + name, desc := splitFlag(l) + fmt.Fprintln(w, " "+tui.Default.Bold.Render(fmt.Sprintf("%-28s", name))+" "+tui.Default.Faint.Render(desc)) + } + } + fmt.Fprintln(w) + if len(rows) > 0 { + tui.Note(w, cmd.CommandPath()+" --help for more") + } + } +} + +// splitFlag splits a pflag usage line into the flag and its description. +func splitFlag(l string) (string, string) { + l = strings.TrimSpace(l) + if i := strings.Index(l, " "); i > 0 { + return strings.TrimSpace(l[:i]), strings.TrimSpace(l[i:]) + } + return l, "" +} diff --git a/cmd/atlas/hooks_cmds.go b/cmd/atlas/hooks_cmds.go index 22d1983..9b65542 100644 --- a/cmd/atlas/hooks_cmds.go +++ b/cmd/atlas/hooks_cmds.go @@ -7,14 +7,19 @@ import ( "fmt" "io" "os" + "os/exec" + "syscall" "time" "github.com/spf13/cobra" gitlink "github.com/tryatlas/atlas-cli/internal/capture/git" "github.com/tryatlas/atlas-cli/internal/capture/store" + capsync "github.com/tryatlas/atlas-cli/internal/capture/sync" "github.com/tryatlas/atlas-cli/internal/hooks" + "github.com/tryatlas/atlas-cli/internal/hosts" "github.com/tryatlas/atlas-cli/internal/listener" + "github.com/tryatlas/atlas-cli/internal/memory/extract" "github.com/tryatlas/atlas-cli/internal/project" // The agent integrations register themselves. @@ -31,7 +36,8 @@ import ( // atlas hooks is what the installed agent hooks run; atlas // hooks git is what the git hooks run. Both are hidden, read their // payload on stdin, never block the agent, and never exit non-zero: an error -// goes to .atlas/cli-hooks.log and shows in atlas status (ADR-0013 §4). +// goes to hooks.log in the CLI state directory and shows in atlas status +// (ADR-0013 §4). func newHooksCmd() *cobra.Command { root := &cobra.Command{Use: "hooks", Short: "Entry points for agent and git hooks (installed by atlas agents add)", Hidden: true, Args: cobra.NoArgs} for _, a := range hooks.All() { @@ -41,6 +47,12 @@ func newHooksCmd() *cobra.Command { } root.AddCommand(ag) } + extract := &cobra.Command{Use: "extract", Short: "Distil memories from a captured session (run detached by the hooks)", Hidden: true, Args: cobra.NoArgs, RunE: runHookExtract} + extract.Flags().String("root", "", "the Project root") + extract.Flags().String("agent", "", "the desktop's agent id") + extract.Flags().String("session", "", "the agent's session id") + extract.Flags().Bool("final", false, "the session ended") + root.AddCommand(extract) git := &cobra.Command{Use: "git", Short: "git hooks", Hidden: true, Args: cobra.NoArgs} git.AddCommand(&cobra.Command{Use: "post-commit", Hidden: true, Args: cobra.ArbitraryArgs, RunE: runGitPostCommit}) root.AddCommand(git) @@ -77,7 +89,7 @@ func runAgentHook(a hooks.Agent, verb string) func(*cobra.Command, []string) err if !hooks.Enabled(ctx, p.Root) { return nil } - d := &hooks.Dispatcher{Root: p.Root, Version: version, Now: time.Now, Deliver: hooks.Deliver(version), Banner: banner(ctx, p.Root)} + d := &hooks.Dispatcher{Root: p.Root, Version: version, Now: time.Now, Deliver: hooks.Deliver(version, syncAfterHook), Banner: banner(ctx, p.Root)} out, err := d.Run(ctx, a, ev) if err != nil { hooks.LogError(p.Root, a.ID(), verb, err) @@ -85,8 +97,96 @@ func runAgentHook(a hooks.Agent, verb string) func(*cobra.Command, []string) err if len(out) > 0 { _, _ = cmd.OutOrStdout().Write(out) } + if err == nil && (ev.Kind == hooks.TurnEnd || ev.Kind == hooks.SessionEnd) { + spawnExtract(p.Root, a.ID(), ev.SessionID, ev.Kind == hooks.SessionEnd) + } + if err == nil && ev.Kind == hooks.SessionEnd { + spawnIndex(p.Root) // the session's document and any new memories + } + if ev.Kind == hooks.SessionStart || ev.Kind == hooks.TurnStart { + ensureListener(p.Root) // after the delivery, so nothing is spooled behind a starting listener + } + return nil + } +} + +// spawnExtract starts `atlas hooks extract` detached: the model call can take +// a minute, and the agent must not wait on it. +func spawnExtract(root, agent, session string, final bool) { + self, err := os.Executable() + if err != nil { + return + } + args := []string{"hooks", "extract", "--root", root, "--agent", agent, "--session", session} + if final { + args = append(args, "--final") + } + c := exec.Command(self, args...) //nolint:gosec // G204: this binary + c.Dir = root + c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if c.Start() == nil { + _ = c.Process.Release() + } +} + +// runHookExtract runs one extraction pass; gates that say "not yet" are +// silent, real failures go to the hooks log. +func runHookExtract(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), 2*extract.Timeout) + defer cancel() + root, _ := cmd.Flags().GetString("root") + agent, _ := cmd.Flags().GetString("agent") + session, _ := cmd.Flags().GetString("session") + final, _ := cmd.Flags().GetBool("final") + if root == "" || session == "" || !hooks.ValidSessionID(session) { return nil } + a, err := newAuth() + if err != nil || a == nil { + return nil //nolint:nilerr // signed out: the desktop's extractor also needs a sign-in + } + s, err := a.Session() + if err != nil { + return nil //nolint:nilerr // signed out + } + n, err := extract.Run(ctx, extract.Options{Root: root, Agent: agent, Session: session, Final: final, + Gateway: hosts.Gateway(), + Auth: func(ctx context.Context) (string, string, error) { + tok, err := a.Tokens().Token(ctx) + return tok, s.ActiveOrgID(), err + }}) + switch { + case errors.Is(err, extract.ErrSkipped): + case err != nil: + hooks.LogError(root, agent, "extract", err) + case n > 0: + hooks.ClearLastError(root) + // New memories: their vectors and documents, as the app reindexes + // after an extraction. This process is already detached. + if p, perr := project.Discover(ctx, root); perr == nil { + _, _ = indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {}) + } + } + return nil +} + +// syncAfterHook sends what a direct hook write just recorded to the cloud, +// within the drain's short budget, so a turn reaches the Project without +// waiting for atlas serve or atlas capture sync. Offline or signed out it +// does nothing; the rows stay pending for the next drain. +func syncAfterHook(ctx context.Context, st *store.Store, d hooks.Delivery) { + b, err := st.Binding(ctx) + if err != nil || b == nil { + return + } + if _, ok := capsync.TargetOf(b); !ok || b.DrainState == store.DrainNotAuthorized { + return + } + budget := capsync.Budget + if d.Tool != nil { + budget = time.Second // the agent waits on a post-tool hook: keep it short + } + _, _ = capsync.Drain(ctx, syncClient(), st, budget, time.Now) } // banner is the SessionStart message: what is on, and which Project. diff --git a/cmd/atlas/index_cmds.go b/cmd/atlas/index_cmds.go new file mode 100644 index 0000000..b7af372 --- /dev/null +++ b/cmd/atlas/index_cmds.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "syscall" + + "github.com/spf13/cobra" + "golang.org/x/sys/unix" + + "github.com/tryatlas/atlas-cli/internal/clistate" + "github.com/tryatlas/atlas-cli/internal/embed" + "github.com/tryatlas/atlas-cli/internal/memory/corpus" + "github.com/tryatlas/atlas-cli/internal/memory/record" + "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui" +) + +// atlas memory index: what the app's memory indexer does for a Project: +// the MiniLM model (downloaded once, shared with the app), a vector for +// every durable memory, and the corpus of documents memory_search returns. +func newMemoryIndexCmd() *cobra.Command { + c := withRun(leaf("index", "Build the memory search index: memory vectors and the project's documents (downloads the model once)", cobra.NoArgs), runMemoryIndex) + c.Flags().Bool("no-download", false, "do not download the model; index keywords only when it is missing") + c.Flags().Bool("background", false, "run quietly, never download, skip when another pass runs (used by hooks)") + _ = c.Flags().MarkHidden("background") + return c +} + +func runMemoryIndex(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + cwd, err := os.Getwd() + if err != nil { + return err + } + p, err := project.Discover(ctx, cwd) + if errors.Is(err, project.ErrNotInitialised) { + return errNoProject + } + if err != nil { + return err + } + bg, _ := cmd.Flags().GetBool("background") + noDL, _ := cmd.Flags().GetBool("no-download") + if bg { + _, err := indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {}) + if err != nil && !errors.Is(err, errIndexBusy) { + // The hooks log is where background work reports. + fmt.Fprintln(cmd.ErrOrStderr(), "atlas: memory index:", err) + } + return nil + } + return tui.WorkProgress(cmd.OutOrStdout(), "Indexing memory", func(step tui.StepFunc, frac tui.FracFunc) (string, error) { + return indexMemory(ctx, p.Root, p.ScopeRoot, !noDL, step, frac) + }) +} + +var errIndexBusy = errors.New("another index pass is running") + +// indexMemory is one pass: model (download when allowed), memory vectors, +// corpus. One pass per Project at a time. +func indexMemory(ctx context.Context, root, scope string, download bool, step tui.StepFunc, frac tui.FracFunc) (string, error) { + if !project.MemorySharing(scope) { + return "Shared memory is off here (atlas memory enable); nothing to index.", nil + } + lock, err := os.OpenFile(clistate.Path(root, "index.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return "", err + } + defer lock.Close() + if unix.Flock(int(lock.Fd()), unix.LOCK_EX|unix.LOCK_NB) != nil { + return "", errIndexBusy + } + defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }() + + if !embed.Available() && download { + step("Downloading the memory search model (" + embed.Size + ", shared with the Atlas app)") + if err := embed.Download(ctx, func(done, total int64) { + if total > 0 { + frac(0.8 * float64(done) / float64(total)) + } + }); err != nil { + return "", fmt.Errorf("download the model: %w", err) + } + } + vectors := record.Vectors() + embedded := 0 + if st, err := record.Open(scope, true); err == nil { + if vectors != nil { + step("Embedding memories") + embedded, err = st.SyncVectors(ctx) + if err != nil && !errors.Is(err, record.ErrNoVectors) { + _ = st.Close() + return "", err + } + } + _ = st.Close() + } else { + return "", err + } + frac(0.85) + step("Reading the project's documents") + docs, err := corpus.Collect(ctx, root, scope) + if err != nil { + return "", err + } + ix, err := corpus.Open(root, true) + if err != nil { + return "", err + } + defer ix.Close() + step(fmt.Sprintf("Indexing %d documents", len(docs))) + stats, err := ix.Update(ctx, docs, corpus.Owned, vectors) + if err != nil { + return "", err + } + frac(1) + msg := fmt.Sprintf("Memory index ready: %d documents (%d new, %d changed, %d removed); %d memories embedded.", + len(docs), stats.Added, stats.Updated, stats.Deleted, embedded) + if vectors == nil { + msg += " No model on this machine: keyword search only (atlas memory index downloads it)." + } + return msg, nil +} + +// spawnIndex runs a background index pass detached, as hooks do after a +// session (the app indexes when a turn finishes and after extraction). +func spawnIndex(root string) { + self, err := os.Executable() + if err != nil { + return + } + c := exec.Command(self, "memory", "index", "--background") //nolint:gosec // G204: this binary + c.Dir = root + c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if c.Start() == nil { + _ = c.Process.Release() + } +} diff --git a/cmd/atlas/init_wizard.go b/cmd/atlas/init_wizard.go new file mode 100644 index 0000000..2c178f5 --- /dev/null +++ b/cmd/atlas/init_wizard.go @@ -0,0 +1,219 @@ +package main + +import ( + "fmt" + "io" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/tryatlas/atlas-cli/internal/clistate" + "github.com/tryatlas/atlas-cli/internal/embed" + "github.com/tryatlas/atlas-cli/internal/hooks" + "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui" +) + +// initStepper numbers atlas init's steps: Organisation, Project, Agents and +// Background sync for a cloud Project; Agents only for a local one. +type initStepper []string + +func initSteps(local bool) initStepper { + s := initStepper{"Organisation", "Project", "Agents", "Background sync"} + if local { + s = initStepper{"Agents"} + } + if !embed.Available() { + s = append(s, "Memory search") + } + return s +} + +func (s initStepper) step(title string) tui.Step { + for i, t := range s { + if t == title { + return tui.Step{N: i + 1, Total: len(s), Title: title} + } + } + return tui.Step{Title: title} +} + +// finishInit runs the steps after the binding: which agents to hook, and +// whether to keep syncing in the background. +func finishInit(cmd *cobra.Command, root string, local, guided bool, steps initStepper) error { + out := cmd.OutOrStdout() + agents, err := pickAgents(cmd, root, guided, steps.step("Agents")) + if err != nil { + return err + } + on, ask := false, false + if !local { + if on, ask, err = pickSync(cmd, root, guided, steps.step("Background sync")); err != nil { + return err + } + } + model := false + if guided && !embed.Available() && project.MemorySharing(project.ScopeRoot(cmd.Context(), root)) { + i, err := chooseStep(steps.step("Memory search"), "Download the memory search model? Agents then find memories by meaning, not only by words.", []tui.Choice{ + {Label: "Download", Detail: embed.Size + ", once; the same model the Atlas app uses"}, + {Label: "Not now", Detail: "keyword search; atlas memory index downloads it later"}, + }, 0) + if err != nil { + return err + } + model = i == 0 + } + if !guided { + if len(agents) > 0 { + if err := installAgents(cmd.Context(), out, root, agents, false, false); err != nil { + return err + } + } + if ask { + msg, err := setBackgroundSync(root, on) + if err != nil { + return err + } + fmt.Fprintln(out, msg) + } + return nil + } + // On a terminal the work runs under the progress bar, each line a step. + if model { + fmt.Fprintln(out) + scope := project.ScopeRoot(cmd.Context(), root) + if err := tui.WorkProgress(out, "Setting up memory search", func(step tui.StepFunc, frac tui.FracFunc) (string, error) { + return indexMemory(cmd.Context(), root, scope, true, step, frac) + }); err != nil { + return err + } + } else if embed.Available() { + spawnIndex(root) // the index, in the background + } + if len(agents) > 0 || ask { + fmt.Fprintln(out) + err := tui.Work(out, "Initializing Atlas in "+root, func(step tui.StepFunc) (string, error) { + w := tui.Lines(io.Discard, func(l string) string { + if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "note:") { + step(l) + } + return l + }) + if len(agents) > 0 { + if err := installAgents(cmd.Context(), w, root, agents, false, false); err != nil { + return "", err + } + } + if ask { + msg, err := setBackgroundSync(root, on) + if err != nil { + return "", err + } + step(msg) + } + return "Atlas is ready in " + root, nil + }) + if err != nil { + return err + } + for _, a := range agents { + if n, ok := a.(hooks.Noter); ok { + tui.Note(out, "↳ "+n.AddNote()) + } + } + } else { + tui.Ready(out, "Atlas is ready in "+root) + } + fmt.Fprintln(out) + tui.Note(out, "Use your agents as usual; sessions and memory are captured as they run.") + tui.Note(out, "atlas status shows what was captured · atlas settings changes any of this") + return nil +} + +// detail says what the machine has of an agent. +func agentDetail(a hooks.Agent, root string) string { + d := "configured here" + if _, err := exec.LookPath(a.Binary()); err == nil { + d = a.Binary() + " on PATH" + } + switch a.State(root) { + case hooks.Current: + d += " · already hooked" + case hooks.Outdated: + d += " · hook outdated" + } + return d +} + +// pickAgents is step 3: --agents or --all-agents, else a checklist of the +// agents detected here with a row that selects them all. +func pickAgents(cmd *cobra.Command, root string, guided bool, step tui.Step) ([]hooks.Agent, error) { + flags := cmd.Flags() + ids, _ := flags.GetStringSlice("agents") + all, _ := flags.GetBool("all-agents") + var detected []hooks.Agent + for _, a := range hooks.All() { + if a.Detect(root) { + detected = append(detected, a) + } + } + switch { + case len(ids) > 0: + return resolveAgents(ids, false, nil) + case all: + if len(detected) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No supported agent was found on this machine (atlas agents list).") + } + return detected, nil + case !guided: + return nil, nil + case len(detected) == 0: + tui.StepDone(cmd.OutOrStdout(), step, "none found on this machine (atlas agents add later)") + return nil, nil + } + choices := make([]tui.Choice, len(detected)) + checked := make([]bool, len(detected)) + for i, a := range detected { + choices[i] = tui.Choice{Label: a.Name(), Detail: agentDetail(a, root)} + checked[i] = true + } + picked, err := chooseMany(step, "Which agents should Atlas capture and give shared memory?", "All agents", choices, checked) + if err != nil { + return nil, err + } + out := make([]hooks.Agent, len(picked)) + for i, n := range picked { + out[i] = detected[n] + } + return out, nil +} + +// pickSync is step 4: background sync on or off. ask is false when nothing +// was asked and no flag given, so the current choice stays. +func pickSync(cmd *cobra.Command, root string, guided bool, step tui.Step) (on, ask bool, err error) { + switch v, _ := cmd.Flags().GetString("sync"); v { + case "on": + return true, true, nil + case "off": + return false, true, nil + case "": + default: + return false, false, fmt.Errorf("--sync must be on or off, not %q", v) + } + if !guided { + return false, false, nil + } + initial := 1 + if clistate.LoadPrefs(root).BackgroundSync { + initial = 0 + } + i, err := chooseStep(step, "Keep syncing in the background? (atlas sync on|off changes it later)", []tui.Choice{ + {Label: "On", Detail: "a background atlas serve sends prompts, tool calls and replies as they happen"}, + {Label: "Off", Detail: "each agent hook syncs on its own; nothing keeps running"}, + }, initial) + if err != nil { + return false, false, err + } + return i == 0, true, nil +} diff --git a/cmd/atlas/main.go b/cmd/atlas/main.go index 2f0b6ae..dd85c19 100644 --- a/cmd/atlas/main.go +++ b/cmd/atlas/main.go @@ -10,6 +10,8 @@ import ( "os" "os/signal" "syscall" + + "github.com/tryatlas/atlas-cli/internal/tui" ) // version is set at build time with -ldflags "-X main.version=…". @@ -24,6 +26,10 @@ func main() { // run executes the command tree and returns the process exit code. func run(args []string, stdout, stderr io.Writer) int { + if interactive() { + tui.Detect() // light or dark palette, before anything draws + stdout = tui.Out(stdout) + } root := newRootCmd() root.SetArgs(args) root.SetIn(stdin) @@ -37,7 +43,9 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, err) return 2 } - fmt.Fprintln(stderr, "atlas:", err) + if !errors.Is(err, tui.ErrCancelled) && !tui.Shown(err) { // the screen already said so + fmt.Fprintln(stderr, "atlas:", err) + } return 1 } return 0 diff --git a/cmd/atlas/main_test.go b/cmd/atlas/main_test.go index db10ead..58c6559 100644 --- a/cmd/atlas/main_test.go +++ b/cmd/atlas/main_test.go @@ -20,6 +20,9 @@ func TestMain(m *testing.M) { _ = os.Setenv("ATLAS_INGEST_URL", "http://127.0.0.1:1") // unreachable unless a test installs a fake data, _ := os.MkdirTemp("", "atlas-data-") // never the user's agents _ = os.Setenv("XDG_DATA_HOME", data) + state, _ := os.MkdirTemp("", "atlas-state-") // never the user's CLI state + _ = os.Setenv("XDG_STATE_HOME", state) + _ = os.Setenv("ATLAS_MODELS_DIR", state) // no embedding model: deterministic os.Exit(m.Run()) } @@ -44,6 +47,7 @@ var wantCommands = []string{ "capture link", "capture retry", "capture sync", + "hooks extract", "hooks git post-commit", "init", "login", @@ -54,13 +58,17 @@ var wantCommands = []string{ "memory enable", "memory export", "memory import", + "memory index", "memory list", "memory verify", "org create", "org list", "org use", "serve", + "settings", "status", + "sync", + "update", "whoami", } @@ -104,8 +112,9 @@ var implemented = map[string]bool{ "memory verify": true, "memory export": true, "memory import": true, "mcp-bridge": true, "serve": true, "capture link": true, "capture sync": true, "acp import": true, "capture retry": true, "acp run": true, "memory browse": true, "memory list": true, "acp check": true, "acp detect": true, "acp install": true, "acp list": true, "acp sessions": true, "acp logout": true, "acp uninstall": true, + "settings": true, "sync": true, "update": true, "memory index": true, "agents add": true, "agents list": true, "agents remove": true, "agents sessions": true, - "hooks git post-commit": true, + "hooks extract": true, "hooks git post-commit": true, } func TestStubsReportNotImplemented(t *testing.T) { diff --git a/cmd/atlas/memory_cmds.go b/cmd/atlas/memory_cmds.go index 906cbe2..d37c3fd 100644 --- a/cmd/atlas/memory_cmds.go +++ b/cmd/atlas/memory_cmds.go @@ -14,6 +14,7 @@ import ( "github.com/tryatlas/atlas-cli/internal/memory/record" "github.com/tryatlas/atlas-cli/internal/memory/server" "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui" "github.com/tryatlas/atlas-cli/internal/tui/memview" ) @@ -37,15 +38,16 @@ func runMemoryVerify(cmd *cobra.Command, _ []string) error { return err } defer st.Close() - rep, err := st.VerifyChain() - if err != nil { - return err - } - if !rep.OK() { - return fmt.Errorf("the revision chain is broken at revision %d (%d revisions checked): history was edited outside Atlas", rep.FirstBad, rep.Revisions) - } - fmt.Fprintf(cmd.OutOrStdout(), "Revision chain intact: %d revisions in %s\n", rep.Revisions, record.Path(st.ScopeRoot)) - return nil + return tui.Work(cmd.OutOrStdout(), "Verifying the memory revision chain", func(tui.StepFunc) (string, error) { + rep, err := st.VerifyChain() + if err != nil { + return "", err + } + if !rep.OK() { + return "", fmt.Errorf("the revision chain is broken at revision %d (%d revisions checked): history was edited outside Atlas", rep.FirstBad, rep.Revisions) + } + return fmt.Sprintf("Revision chain intact: %d revisions in %s", rep.Revisions, record.Path(st.ScopeRoot)), nil + }) } func runMemoryExport(cmd *cobra.Command, args []string) error { @@ -54,12 +56,13 @@ func runMemoryExport(cmd *cobra.Command, args []string) error { return err } defer st.Close() - n, err := st.Export(cmd.Context(), args[0]) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Exported %d memories to %s (MEMORY.md, decisions.md, architecture.md, facts.md, failures.md).\n", n, args[0]) - return nil + return tui.Work(cmd.OutOrStdout(), "Exporting shared memory", func(tui.StepFunc) (string, error) { + n, err := st.Export(cmd.Context(), args[0]) + if err != nil { + return "", err + } + return fmt.Sprintf("Exported %d memories to %s (MEMORY.md, decisions.md, architecture.md, facts.md, failures.md).", n, args[0]), nil + }) } func runMemoryImport(cmd *cobra.Command, args []string) error { @@ -75,12 +78,13 @@ func runMemoryImport(cmd *cobra.Command, args []string) error { return err } defer st.Close() - res, err := st.Import(cmd.Context(), args[0], now().UnixMilli()) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Imported %d memories as candidates (%d already held). Agents see them as unconfirmed until one restates them.\n", res.Imported, res.Skipped) - return nil + return tui.Work(cmd.OutOrStdout(), "Importing memories", func(tui.StepFunc) (string, error) { + res, err := st.Import(cmd.Context(), args[0], now().UnixMilli()) + if err != nil { + return "", err + } + return fmt.Sprintf("Imported %d memories as candidates (%d already held). Agents see them as unconfirmed until one restates them.", res.Imported, res.Skipped), nil + }) } func gateRoot(cmd *cobra.Command, cwd string) string { @@ -167,9 +171,35 @@ func runMemoryList(cmd *cobra.Command, _ []string) error { for _, h := range hits { es = append(es, h.Entry) } + } else if all, _ := cmd.Flags().GetBool("all"); all { + // Candidates too, as the app's memory panel shows them. + if es, err = st.List(ctx, kind, 10000); err != nil { + return err + } } else if es, err = st.Active(ctx, kinds...); err != nil { return err } + if interactive() { + out := cmd.OutOrStdout() + tui.Header(out, "memory", "list") + fmt.Fprintln(out) + if len(es) == 0 { + tui.Note(out, "No memories here yet.") + return nil + } + var rows [][]string + for _, e := range es { + state := tui.Dot(tui.Good, e.State) + if e.State == record.StateCandidate { + state = tui.Dot(tui.Idle, e.State) + } + rows = append(rows, []string{fmt.Sprint(e.ID), strings.ReplaceAll(e.Kind, "_", " "), state, + tui.Fit(strings.Join(strings.Fields(e.Content), " "), max(tui.Width()-50, 20))}) + } + fmt.Fprint(out, tui.Table([]string{"id", "kind", "state", "memory"}, rows)) + tui.Note(out, fmt.Sprintf("%d memories · atlas memory browse to read them", len(es))) + return nil + } for _, e := range es { fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\t%s\t%s\n", e.ID, e.Kind, e.State, strings.Join(strings.Fields(e.Content), " ")) } diff --git a/cmd/atlas/project_cmds.go b/cmd/atlas/project_cmds.go index 20f6cc5..2cc773a 100644 --- a/cmd/atlas/project_cmds.go +++ b/cmd/atlas/project_cmds.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "context" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "strings" "time" + "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/tryatlas/atlas-cli/internal/auth" @@ -25,7 +27,8 @@ import ( // Seams replaced in tests. var ( interactive = tui.Interactive - choose = tui.Select + chooseStep = tui.StepSelect + chooseMany = tui.StepMulti now = time.Now ) @@ -34,10 +37,11 @@ const lockedHelp = "another Atlas window is recording this Project; close the fo func runInit(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + errOut := cmd.ErrOrStderr() flags := cmd.Flags() local, _ := flags.GetBool("local") yes, _ := flags.GetBool("yes") + guided := interactive() && !yes cwd, err := os.Getwd() if err != nil { @@ -48,6 +52,12 @@ func runInit(cmd *cobra.Command, _ []string) error { return err } scope := project.ScopeRoot(ctx, launch) + if guided { + tui.Animate(cmd.OutOrStdout(), func(t float64) string { + return lipgloss.JoinHorizontal(lipgloss.Center, tui.Logo(t), " ", + tui.Default.Bold.Render("Set up Atlas")+"\n"+tui.Default.Faint.Render(launch)) + "\n" + }) + } if launch != scope { fmt.Fprintf(errOut, "Note: %s is inside the repository %s.\n"+ @@ -64,13 +74,46 @@ func runInit(cmd *cobra.Command, _ []string) error { if a, err = newAuth(); err != nil { return err } - if sess, err = a.Refresh(ctx); errors.Is(err, auth.ErrSignedOut) { - return err - } else if sess == nil { + sess, err = a.Refresh(ctx) + if errors.Is(err, auth.ErrSignedOut) && guided { + i, cerr := chooseStep(tui.Step{Title: "Account"}, "You are not signed in to Atlas.", []tui.Choice{ + {Label: "Sign in", Detail: "opens the browser; sessions and memory sync to a cloud Project"}, + {Label: "Set up locally", Detail: "nothing leaves this machine"}, + }, 0) + if cerr != nil { + return cerr + } + if i == 1 { + local = true + } else { + if err := runLogin(cmd, nil); err != nil { + return err + } + sess, err = a.Refresh(ctx) + } + } + if !local && sess == nil { return err } } + steps := initSteps(local) + + if err := bindFolder(cmd, a, sess, local, launch, scope, steps); err != nil { + return err + } + ensureMemoryRecord(scope) + return finishInit(cmd, launch, local, guided, steps) +} +// bindFolder writes the binding under the writer lock and lets it go. +func bindFolder(cmd *cobra.Command, a *auth.Auth, sess *auth.Session, local bool, launch, scope string, steps initStepper) error { + ctx := cmd.Context() + out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + yes, _ := cmd.Flags().GetBool("yes") + guided := interactive() && !yes + if guided { + out = tui.Indent(out) // inside the steps' tree + } lock, err := store.Lock(launch) if errors.Is(err, store.ErrLocked) { return errors.New(lockedHelp) @@ -105,16 +148,20 @@ func runInit(cmd *cobra.Command, _ []string) error { if err := st.PutBinding(ctx, b, now()); err != nil { return err } - fmt.Fprintf(out, "Bound %s locally. Nothing is synced.\n", launch) + if guided { + tui.StepDone(cmd.OutOrStdout(), tui.Step{Title: "Project"}, "local · nothing is synced") + } else { + fmt.Fprintf(out, "Bound %s locally. Nothing is synced.\n", launch) + } return nil } - org, err := pickOrg(cmd, sess) + org, err := pickOrg(cmd, sess, steps.step("Organisation")) if err != nil { return err } ic := ingest.New(hosts.Ingest(), a.Tokens()) - ws, how, err := pickProject(cmd, ic, org, launch, fp) + ws, how, err := pickProject(cmd, ic, org, launch, fp, steps.step("Project")) if err != nil { return err } @@ -128,13 +175,27 @@ func runInit(cmd *cobra.Command, _ []string) error { if err := st.BindCloud(ctx, b, now()); err != nil { return err } - fmt.Fprintf(out, "Bound %s to %s / %s (%s).\n", launch, org.Name, ws.Label(), how) + if !guided { // on a terminal the Project step's line says it + fmt.Fprintf(out, "Bound %s to %s / %s (%s).\n", launch, org.Name, ws.Label(), how) + } if launch != scope { fmt.Fprintf(out, "Shared memory for this folder lives at %s.\n", filepath.Join(scope, ".atlas", "memory")) } return nil } +// ensureMemoryRecord creates /.atlas/memory/memory.sqlite when shared +// memory is on, so a bound folder has its record from the start (the app +// creates it on the first session; an empty record changes nothing). +func ensureMemoryRecord(scope string) { + if !project.MemorySharing(scope) { + return + } + if st, err := record.Open(scope, true); err == nil { + _ = st.Close() + } +} + // confirm asks a yes/no question; without a terminal, --yes is required. func confirm(cmd *cobra.Command, yes bool, question string) error { if yes { @@ -151,7 +212,7 @@ func confirm(cmd *cobra.Command, yes bool, question string) error { return tui.ErrCancelled } -func pickOrg(cmd *cobra.Command, s *auth.Session) (auth.Org, error) { +func pickOrg(cmd *cobra.Command, s *auth.Session, step tui.Step) (auth.Org, error) { want, _ := cmd.Flags().GetString("org") var orgs []auth.Org if s.Identity != nil && s.Identity.Orgs != nil { @@ -163,35 +224,46 @@ func pickOrg(cmd *cobra.Command, s *auth.Session) (auth.Org, error) { if want != "" { for _, o := range orgs { if o.ID == want { + stepDone(cmd, step, o.Name) return o, nil } } return auth.Org{}, fmt.Errorf("%s is not one of your organisations (atlas org list)", want) } if len(orgs) == 1 { + stepDone(cmd, step, orgs[0].Name+" (your only organisation)") return orgs[0], nil } if !interactive() { return auth.Org{}, errors.New("you belong to several organisations; pass --org (atlas org list)") } - labels := make([]string, len(orgs)) + choices := make([]tui.Choice, len(orgs)) initial := 0 for i, o := range orgs { - labels[i] = fmt.Sprintf("%s (%s)", o.Name, o.ID) + choices[i] = tui.Choice{Label: o.Name, Detail: o.ID} if o.ID == s.ActiveOrgID() { initial = i + choices[i].Detail += " · active" } } - i, err := choose("Which organisation?", labels, initial) + i, err := chooseStep(step, "Which organisation should this folder belong to?", choices, initial) if err != nil { return auth.Org{}, err } return orgs[i], nil } +// stepDone prints an answered step on a terminal; scripts get the summary +// line only. +func stepDone(cmd *cobra.Command, step tui.Step, answer string) { + if interactive() { + tui.StepDone(cmd.OutOrStdout(), step, answer) + } +} + // pickProject chooses the cloud Project: an explicit --project, a new one with // --create, else the server's fingerprint match, else a choice from the list. -func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch string, fp project.GitFingerprint) (ingest.Workspace, string, error) { +func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch string, fp project.GitFingerprint, step tui.Step) (ingest.Workspace, string, error) { ctx := cmd.Context() flags := cmd.Flags() explicit, _ := flags.GetString("project") @@ -214,10 +286,19 @@ func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch str switch { case explicit != "": - return connect(explicit, nil) + ws, how, err := connect(explicit, nil) + if err == nil { + stepDone(cmd, step, ws.Label()) + } + return ws, how, err case create: - return createProject(cmd, ic, org, launch, fp) + ws, how, err := createProject(cmd, ic, org, launch, fp) + if err == nil { + stepDone(cmd, step, ws.Label()+" (new)") + } + return ws, how, err } + newLabel := tui.Choice{Label: "+ Create a new Project", Detail: "“" + defaultSlug(cmd, launch) + "”"} if fp.RootCommitSHA != nil || fp.GitURL != nil { res, err := ic.Connect(ctx, ingest.ConnectRequest{OrgID: org.ID, RootCommitSHA: fp.RootCommitSHA, GitURL: fp.GitURL}) @@ -226,12 +307,37 @@ func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch str } switch res.Status { case "connected": - return ingest.Workspace{ID: res.WorkspaceID, Slug: deref(res.Slug)}, "matched by " + res.Reason, nil + matched := ingest.Workspace{ID: res.WorkspaceID, Slug: deref(res.Slug)} + if !interactive() { + return matched, "matched by " + res.Reason, nil + } + // Offer the match first, the org's other Projects after it. + list, _ := ic.Workspaces(ctx, org.ID) + opts := []ingest.Workspace{matched} + for _, w := range list { + if w.ID == matched.ID { + opts[0] = w + } else { + opts = append(opts, w) + } + } + choices := workspaceChoices(opts) + choices[0].Detail = "matches this repository (" + res.Reason + ")" + i, err := chooseStep(step, "Which Project in "+org.Name+"?", append(choices, newLabel), 0) + switch { + case err != nil: + return ingest.Workspace{}, "", err + case i == 0: + return opts[0], "matched by " + res.Reason, nil + case i == len(opts): + return createProject(cmd, ic, org, launch, fp) + } + return connect(opts[i].ID, &opts[i].Slug) case "ambiguous": if !interactive() { return ingest.Workspace{}, "", fmt.Errorf("several Projects match this repository (%s); pass --project ", workspaceList(res.Candidates)) } - i, err := choose("Several Projects match this repository. Which one?", workspaceLabels(res.Candidates), 0) + i, err := chooseStep(step, "Several Projects in "+org.Name+" match this repository. Which one?", workspaceChoices(res.Candidates), 0) if err != nil { return ingest.Workspace{}, "", err } @@ -250,8 +356,7 @@ func pickProject(cmd *cobra.Command, ic *ingest.Client, org auth.Org, launch str } return ingest.Workspace{}, "", fmt.Errorf("no Project matches this repository; pass --project (%s) or --create", workspaceList(list)) } - newLabel := fmt.Sprintf("Create a new Project “%s”", defaultSlug(cmd, launch)) - i, err := choose("Which Project in "+org.Name+"?", append(workspaceLabels(list), newLabel), len(list)) + i, err := chooseStep(step, "Which Project in "+org.Name+"?", append(workspaceChoices(list), newLabel), len(list)) if err != nil { return ingest.Workspace{}, "", err } @@ -300,6 +405,14 @@ func workspaceLabels(ws []ingest.Workspace) []string { return out } +func workspaceChoices(ws []ingest.Workspace) []tui.Choice { + out := make([]tui.Choice, len(ws)) + for i, w := range ws { + out[i] = tui.Choice{Label: w.Label(), Detail: w.ID} + } + return out +} + func workspaceList(ws []ingest.Workspace) string { return strings.Join(workspaceLabels(ws), ", ") } func deref(p *string) string { @@ -361,7 +474,7 @@ func runMemoryGate(enabled bool) func(*cobra.Command, []string) error { if enabled { state = "on" } - fmt.Fprintf(cmd.OutOrStdout(), "Shared memory is %s for %s.\n", state, dir) + tui.Done(cmd.OutOrStdout(), fmt.Sprintf("Shared memory is %s for %s.", state, dir)) return nil } } @@ -393,9 +506,9 @@ func runCaptureGate(enabled bool) func(*cobra.Command, []string) error { return err } if enabled { - fmt.Fprintf(cmd.OutOrStdout(), "Capture is on for %s.\n", p.Root) + tui.Done(cmd.OutOrStdout(), fmt.Sprintf("Capture is on for %s.", p.Root)) } else { - fmt.Fprintf(cmd.OutOrStdout(), "Capture is paused for %s. What was recorded still syncs.\n", p.Root) + tui.Done(cmd.OutOrStdout(), fmt.Sprintf("Capture is paused for %s. What was recorded still syncs.", p.Root)) } return nil } @@ -404,8 +517,19 @@ func runCaptureGate(enabled bool) func(*cobra.Command, []string) error { // ── Status ─────────────────────────────────────────────────────────────────── func runStatus(cmd *cobra.Command, _ []string) error { + if !interactive() { + return statusLines(cmd, cmd.OutOrStdout()) + } + var buf bytes.Buffer + err := statusLines(cmd, &buf) + renderStatus(cmd.OutOrStdout(), buf.String()) + return err +} + +// statusLines prints "Label: value" lines, one fact each; on a terminal +// renderStatus draws them as a card. +func statusLines(cmd *cobra.Command, w io.Writer) error { ctx := cmd.Context() - w := cmd.OutOrStdout() var sess *auth.Session if a, err := newAuth(); err == nil { if s, err := a.Session(); err == nil { @@ -461,6 +585,7 @@ func runStatus(cmd *cobra.Command, _ []string) error { } printCaptureHealth(ctx, w, st, p.Root, b) printMemoryHealth(ctx, w, p.ScopeRoot) + printSync(w, p.Root) printAgents(ctx, w, p.Root) return nil } diff --git a/cmd/atlas/serve_cmds.go b/cmd/atlas/serve_cmds.go index 2b437c2..173c972 100644 --- a/cmd/atlas/serve_cmds.go +++ b/cmd/atlas/serve_cmds.go @@ -11,7 +11,7 @@ import ( "os" "os/exec" "os/signal" - "path/filepath" + "strings" "syscall" "time" @@ -21,12 +21,14 @@ import ( gitlink "github.com/tryatlas/atlas-cli/internal/capture/git" "github.com/tryatlas/atlas-cli/internal/capture/store" capsync "github.com/tryatlas/atlas-cli/internal/capture/sync" + "github.com/tryatlas/atlas-cli/internal/clistate" "github.com/tryatlas/atlas-cli/internal/hooks" "github.com/tryatlas/atlas-cli/internal/hosts" "github.com/tryatlas/atlas-cli/internal/ingest" "github.com/tryatlas/atlas-cli/internal/listener" "github.com/tryatlas/atlas-cli/internal/project" atlasrun "github.com/tryatlas/atlas-cli/internal/run" + "github.com/tryatlas/atlas-cli/internal/tui" ) // ── atlas capture sync / retry ─────────────────────────────────────────────── @@ -59,9 +61,9 @@ func runCaptureSync(retry bool) func(*cobra.Command, []string) error { return fmt.Errorf("the running listener did not answer: %w", err) } if retry { - fmt.Fprintf(out, "Requeued %d failed row(s).\n", ans.Requeued) + tui.Done(out, fmt.Sprintf("Requeued %d failed row(s).", ans.Requeued)) } - fmt.Fprintln(out, "Asked the running listener to sync now.") + tui.Done(out, "Asked the running listener to sync now.") return nil } if err != nil { @@ -73,56 +75,64 @@ func runCaptureSync(retry bool) func(*cobra.Command, []string) error { return err } defer st.Close() + title := "Syncing to the cloud Project" if retry { - n, err := st.RetryFailed(ctx) - if err != nil { - return err - } - fmt.Fprintf(out, "Requeued %d failed row(s).\n", n) - } - b, err := st.Binding(ctx) - if err != nil { - return err - } - if _, ok := capsync.TargetOf(b); !ok { - fmt.Fprintln(out, "This Project is local: nothing syncs. Bind it to a cloud Project with atlas init.") - return nil + title = "Retrying failed rows" } - if b.DrainState == store.DrainNotAuthorized { - // A human action re-checks access (the desktop re-registers). - if err := st.SetDrainState(ctx, store.DrainOK, time.Now()); err != nil { - return err + return tui.Work(out, title, func(step tui.StepFunc) (string, error) { + var lines []string + if retry { + n, err := st.RetryFailed(ctx) + if err != nil { + return "", err + } + lines = append(lines, fmt.Sprintf("Requeued %d failed row(s).", n)) + step(lines[0]) } - } - total := capsync.Report{} - c := syncClient() - for { - rep, err := capsync.Drain(ctx, c, st, capsync.Budget, time.Now) + b, err := st.Binding(ctx) if err != nil { - return err + return "", err } - total.Sent += rep.Sent - total.Failed += rep.Failed - total.Retired += rep.Retired - total.Outcome = rep.Outcome - if rep.Outcome != capsync.Done || rep.Sent+rep.Failed == 0 { - break + if _, ok := capsync.TargetOf(b); !ok { + return strings.Join(append(lines, "This Project is local: nothing syncs. Bind it to a cloud Project with atlas init."), "\n"), nil } - } - counts, _ := st.OutboxCounts(ctx) - fmt.Fprintf(out, "Sent %d row(s); %d failed. Outbox: %d pending, %d failed.\n", - total.Sent, total.Failed+int(total.Retired), counts[store.SyncPending], counts[store.SyncFailed]) - switch total.Outcome { - case capsync.Offline: - return errors.New("the ingest server is unreachable; the listener retries with backoff") - case capsync.RateLimited: - return errors.New("the ingest server asked to slow down; try again later") - case capsync.NotAuthorized: - return errors.New("the ingest server refused this Project (403); check your access with atlas status") - case capsync.SignedOut: - return errors.New("not signed in; run atlas login") - } - return nil + if b.DrainState == store.DrainNotAuthorized { + // A human action re-checks access (the desktop re-registers). + if err := st.SetDrainState(ctx, store.DrainOK, time.Now()); err != nil { + return "", err + } + } + total := capsync.Report{} + c := syncClient() + for { + rep, err := capsync.Drain(ctx, c, st, capsync.Budget, time.Now) + if err != nil { + return "", err + } + total.Sent += rep.Sent + total.Failed += rep.Failed + total.Retired += rep.Retired + total.Outcome = rep.Outcome + step(fmt.Sprintf("%d row(s) sent", total.Sent)) + if rep.Outcome != capsync.Done || rep.Sent+rep.Failed == 0 { + break + } + } + counts, _ := st.OutboxCounts(ctx) + summary := fmt.Sprintf("Sent %d row(s); %d failed. Outbox: %d pending, %d failed.", + total.Sent, total.Failed+int(total.Retired), counts[store.SyncPending], counts[store.SyncFailed]) + switch total.Outcome { + case capsync.Offline: + return "", fmt.Errorf("%s The ingest server is unreachable; the listener retries with backoff", summary) + case capsync.RateLimited: + return "", fmt.Errorf("%s The ingest server asked to slow down; try again later", summary) + case capsync.NotAuthorized: + return "", fmt.Errorf("%s The ingest server refused this Project (403); check your access with atlas status", summary) + case capsync.SignedOut: + return "", fmt.Errorf("%s Not signed in; run atlas login", summary) + } + return strings.Join(append(lines, summary), "\n"), nil + }) } } @@ -156,7 +166,7 @@ func postSync(ctx context.Context, sock string, retry bool) (listener.SyncAnswer const daemonEnv = "ATLAS_SERVE_DAEMON" // serveLog is where a detached listener's stderr goes. -func serveLog(root string) string { return filepath.Join(root, store.DirName, "cli-serve.log") } +func serveLog(root string) string { return clistate.Path(root, "serve.log") } // runServe runs the listener until interrupted; --daemon detaches it and // --stop ends a detached one. @@ -181,6 +191,15 @@ func runServe(cmd *cobra.Command, _ []string) error { if err != nil { return err } + var live *tui.Live + if os.Getenv(daemonEnv) == "" && interactive() { + live = tui.StartLive([]string{"serve"}, [][2]string{ + {"Project", p.Root}, + {"Memory", h.Listener.MCPURL()}, + {"Sync", tui.Dot(tui.Good, "drain running")}, + {"Hooks", tui.Dot(tui.Good, "delivered here")}, + }) + } if h.Capturing() { h.Listener.SetHookSink(func(ctx context.Context, body []byte) error { var d hooks.Delivery @@ -188,7 +207,20 @@ func runServe(cmd *cobra.Command, _ []string) error { return err } d.Root = p.Root - return hooks.Apply(ctx, h.Recorder, h.Listener.Memory, []hooks.Delivery{d}) + if h.Stale() { + return errStaleStore // the hook writes to the new store itself + } + err := hooks.Apply(ctx, h.Recorder, h.Listener.Memory, []hooks.Delivery{d}) + if live != nil { + live.Event(deliveryEvent(d, err)) + } + if err != nil { + return err + } + if h.Sync != nil && !d.End { + h.Sync.Force() // the prompt, call or reply goes to the cloud now, not at the next tick + } + return nil }) h.Listener.SetLinker(func(ctx context.Context) error { _, err := gitlink.Link(ctx, h.Store, p.Root, time.Now) @@ -209,44 +241,137 @@ func runServe(cmd *cobra.Command, _ []string) error { logImport(cmd.ErrOrStderr(), res, err, false) }) } - if os.Getenv(daemonEnv) == "" { + if os.Getenv(daemonEnv) == "" && live == nil { fmt.Fprintf(out, "Serving %s: memory on %s, sync drain running, agent hooks delivered here. Ctrl-C to stop.\n", p.Root, h.Listener.MCPURL()) if im != nil { fmt.Fprintf(out, "Importing sessions from the agents' own UIs every %s.\n", every) } } + // The memory index follows the record and the sessions, as the app's + // indexer does: now, then every 15 minutes (never downloading). + go func() { + t := time.NewTicker(15 * time.Minute) + defer t.Stop() + for { + if project.MemorySharing(p.ScopeRoot) { + _, _ = indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {}) + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } + }() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) - select { - case <-sig: - case <-ctx.Done(): + // A store deleted or re-created under the listener: stop rather than + // write into a file no one can see. Hooks then write directly, and the + // next one restarts a listener when background sync is on. + stale := make(chan struct{}) + go func() { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if h.Stale() { + fmt.Fprintln(cmd.ErrOrStderr(), "atlas: this Project's .atlas was replaced; stopping the listener") + close(stale) + return + } + } + } + }() + if live != nil { + ended := make(chan struct{}) + go func() { _ = live.Run(); close(ended) }() + select { + case <-ended: // q, esc or ctrl+c on the screen + case <-sig: + live.Stop() + <-ended + case <-stale: + live.Stop() + <-ended + case <-ctx.Done(): + live.Stop() + <-ended + } + } else { + select { + case <-sig: + case <-stale: + case <-ctx.Done(): + } } if im != nil { im.Close() } res, err := h.Close() if res.Checkpoints > 0 && os.Getenv(daemonEnv) == "" { - fmt.Fprintf(out, "Linked %d session commit(s).\n", res.Checkpoints) + tui.Done(out, fmt.Sprintf("Linked %d session commit(s).", res.Checkpoints)) + } + if live != nil && err == nil { + tui.Done(out, "Listener stopped.") } return err } +// errStaleStore refuses a hook delivery to a listener whose store was +// replaced; the hook falls back to writing the store itself. +var errStaleStore = errors.New("the listener's store was replaced") + +// deliveryEvent is a hook delivery as one line of serve's live feed. +func deliveryEvent(d hooks.Delivery, err error) tui.LiveEvent { + e := tui.LiveEvent{At: time.Now(), Who: d.Session.Agent, Bad: err != nil} + switch { + case err != nil: + e.What = "failed: " + err.Error() + case d.Start: + e.What = "session started" + case d.Open != nil: + e.What = "prompt " + tui.Default.Faint.Render(strings.Join(strings.Fields(d.Open.Prompt), " ")) + case d.Tool != nil: + e.What = "tool " + tui.Default.Faint.Render(d.Tool.Name+" "+strings.Join(d.Changed, ", ")) + case d.Turn != nil: + e.What = "reply " + tui.Default.Faint.Render("turn closed") + case d.End: + e.What = "session ended" + default: + e.What = "event" + } + if d.LinkFrom != "" && err == nil { + e.What += tui.Default.Success.Render(" ✓ commit linked") + } + return e +} + // startDaemon re-runs `atlas serve` detached in its own session and waits for // its run file. func startDaemon(cmd *cobra.Command, root string) error { - if rf := listener.ReadRunFile(root); rf != nil && alive(rf.PID) { - return fmt.Errorf("a listener is already running for this Project (pid %d)", rf.PID) + every, _ := cmd.Flags().GetDuration("import-every") + pid, port, err := launchListener(root, every) + if err != nil { + return err } + fmt.Fprintf(cmd.OutOrStdout(), "Listener started (pid %d) on 127.0.0.1:%d. Stop it with atlas sync off.\n", pid, port) + return nil +} + +// spawnListener starts the detached listener without waiting for it. +func spawnListener(root string, every time.Duration) (*exec.Cmd, error) { self, err := os.Executable() if err != nil { - return err + return nil, err } logf, err := os.OpenFile(serveLog(root), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { - return err + return nil, err } defer logf.Close() - every, _ := cmd.Flags().GetDuration("import-every") args := []string{"serve"} if every > 0 { args = append(args, "--import-every", every.String()) @@ -257,46 +382,69 @@ func startDaemon(cmd *cobra.Command, root string) error { child.Stdout, child.Stderr = logf, logf child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := child.Start(); err != nil { - return err + return nil, err + } + return child, nil +} + +// launchListener starts the detached listener and waits for its run file. +func launchListener(root string, every time.Duration) (pid, port int, err error) { + if rf := listener.ReadRunFile(root); rf != nil && alive(rf.PID) { + return 0, 0, fmt.Errorf("a listener is already running for this Project (pid %d)", rf.PID) } - pid := child.Process.Pid + child, err := spawnListener(root, every) + if err != nil { + return 0, 0, err + } + pid = child.Process.Pid _ = child.Process.Release() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if rf := listener.ReadRunFile(root); rf != nil && rf.PID == pid { - fmt.Fprintf(cmd.OutOrStdout(), "Listener started (pid %d) on 127.0.0.1:%d. Stop it with atlas serve --stop.\n", pid, rf.Port) - return nil + return pid, rf.Port, nil } if !alive(pid) { break } time.Sleep(100 * time.Millisecond) } - return fmt.Errorf("the listener did not start; see %s", serveLog(root)) + return 0, 0, fmt.Errorf("the listener did not start; see %s", serveLog(root)) } // stopDaemon signals the listener named in the run file and waits for it. func stopDaemon(out interface{ Write([]byte) (int, error) }, root string) error { - rf := listener.ReadRunFile(root) - if rf == nil || !alive(rf.PID) { + pid, err := haltListener(root) + if err != nil { + return err + } + if pid == 0 { fmt.Fprintln(out, "No listener is running for this Project.") return nil } + fmt.Fprintf(out, "Stopped the listener (pid %d).\n", pid) + return nil +} + +// haltListener stops the detached listener; pid 0 means none ran. +func haltListener(root string) (int, error) { + rf := listener.ReadRunFile(root) + if rf == nil || !alive(rf.PID) { + return 0, nil + } if !rf.Daemon { - return fmt.Errorf("the listener (pid %d) belongs to atlas run or a foreground atlas serve; exit that instead", rf.PID) + return 0, fmt.Errorf("the listener (pid %d) belongs to atlas run or a foreground atlas serve; exit that instead", rf.PID) } if err := syscall.Kill(rf.PID, syscall.SIGTERM); err != nil { - return err + return 0, err } deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { if !alive(rf.PID) { - fmt.Fprintf(out, "Stopped the listener (pid %d).\n", rf.PID) - return nil + return rf.PID, nil } time.Sleep(100 * time.Millisecond) } - return fmt.Errorf("the listener (pid %d) did not stop", rf.PID) + return 0, fmt.Errorf("the listener (pid %d) did not stop", rf.PID) } func alive(pid int) bool { diff --git a/cmd/atlas/settings_cmds.go b/cmd/atlas/settings_cmds.go new file mode 100644 index 0000000..15fb2e6 --- /dev/null +++ b/cmd/atlas/settings_cmds.go @@ -0,0 +1,399 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/spf13/cobra" + + "github.com/tryatlas/atlas-cli/internal/auth" + "github.com/tryatlas/atlas-cli/internal/capture/store" + "github.com/tryatlas/atlas-cli/internal/clistate" + "github.com/tryatlas/atlas-cli/internal/embed" + "github.com/tryatlas/atlas-cli/internal/hooks" + "github.com/tryatlas/atlas-cli/internal/hooks/mcpconfig" + "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui/settings" +) + +// atlas settings: account, Project gates, agents and background sync on +// one screen. Without a terminal it prints the same rows. +func newSettingsCmd() *cobra.Command { + return withRun(leaf("settings", "Change this Project's capture, shared memory, agents and background sync on one screen", cobra.NoArgs), runSettings) +} + +func runSettings(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + cwd, err := os.Getwd() + if err != nil { + return err + } + be := &settingsBackend{cwd: cwd} + title := cwd + if p, err := project.Discover(ctx, cwd); err == nil { + title = p.Root + } + if !interactive() { + secs, err := be.Load(ctx) + if err != nil { + return err + } + printSettings(cmd.OutOrStdout(), secs) + return nil + } + _, err = tea.NewProgram(settings.New(ctx, be, title), tea.WithContext(ctx)).Run() + return err +} + +// printSettings is the screen as text, for pipes and scripts. +func printSettings(w io.Writer, secs []settings.Section) { + for i, s := range secs { + if i > 0 { + fmt.Fprintln(w) + } + fmt.Fprintln(w, s.Title) + for _, it := range s.Items { + v := it.Value + switch it.Kind { + case settings.Toggle: + st := "off" + if it.On { + st = "on" + } + v = strings.TrimSpace(st + " " + it.Value) + case settings.Action: + continue + } + fmt.Fprintf(w, " %-22s %s\n", it.Label, v) + } + } +} + +// settingsBackend reads the state the commands read, and changes it through +// the commands themselves, so the screen and the CLI cannot disagree. +type settingsBackend struct{ cwd string } + +func (b *settingsBackend) project(ctx context.Context) *project.Project { + p, err := project.Discover(ctx, b.cwd) + if err != nil { + return nil + } + return p +} + +const needInit = "Run atlas init in this folder first." + +// Load builds the sections. +func (b *settingsBackend) Load(ctx context.Context) ([]settings.Section, error) { + p := b.project(ctx) + secs := []settings.Section{b.account(), b.projectSection(ctx, p), b.agents(p), b.sync(ctx, p), b.model(p), b.about(p)} + return secs, nil +} + +func (b *settingsBackend) account() settings.Section { + sec := settings.Section{Title: "Account", Icon: "◉"} + a, err := newAuth() + var s *auth.Session + if err == nil { + s, _ = a.Session() + } + if s == nil { + sec.Items = []settings.Item{{Label: "Signed in as", Value: "not signed in", + Detail: "Run atlas login to sync sessions and memory to a cloud Project."}} + return sec + } + who := "signed in" + if s.Identity != nil { + who = fmt.Sprintf("%s <%s>", s.Identity.Name, s.Identity.Email) + } + org := "none chosen" + if id := s.ActiveOrgID(); id != "" { + org = orgLabel(s, id) + } + n := 0 + if s.Identity != nil && s.Identity.Orgs != nil { + n = len(*s.Identity.Orgs) + } + sec.Items = []settings.Item{ + {Label: "Signed in as", Value: who, Detail: "atlas logout signs out and deletes the local session file."}, + {Label: "Active organisation", Value: org, Detail: "atlas org use changes it; atlas org list shows them."}, + {Label: "Organisations", Value: fmt.Sprint(n)}, + {Label: "Session file", Value: a.Store.Path(), Detail: "The only secret Atlas keeps on disk (mode 0600)."}, + } + return sec +} + +func (b *settingsBackend) projectSection(ctx context.Context, p *project.Project) settings.Section { + sec := settings.Section{Title: "Project", Icon: "▣"} + var dir string + if p == nil { + dir, _ = project.Canonical(b.cwd) + sec.Items = append(sec.Items, settings.Item{Label: "Folder", Value: dir + " (not initialised)", Detail: needInit}) + } else { + dir = p.Root + sec.Items = append(sec.Items, settings.Item{Label: "Folder", Value: p.Root}) + } + var bind *store.Binding + if p != nil { + if st, err := store.Open(p.Root, false); err == nil { + bind, _ = st.Binding(ctx) + _ = st.Close() + } + var sess *auth.Session + if a, err := newAuth(); err == nil { + sess, _ = a.Session() + } + if bind != nil { + sec.Items = append(sec.Items, settings.Item{Label: "Bound to", Value: describeBinding(bind, sess), + Detail: "atlas init binds the folder again (another organisation or Project)."}) + } + if p.ScopeRoot != p.Root { + sec.Items = append(sec.Items, settings.Item{Label: "Memory lives at", Value: p.ScopeRoot}) + } + } + capture := settings.Item{Key: "capture", Label: "Session capture", Kind: settings.Toggle, On: bind != nil && bind.Enabled, + Detail: "Record prompts, replies, tool calls and file changes of every agent session here. Pausing keeps what was recorded and still syncs it."} + if bind == nil { + capture.Disabled = needInit + } + sec.Items = append(sec.Items, capture, + settings.Item{Key: "memory", Label: "Shared memory", Kind: settings.Toggle, On: project.MemorySharing(dir), + Detail: "Agents read and write what every session learned in this repository, through the atlas_memory MCP server. Nothing is injected into prompts."}) + return sec +} + +func (b *settingsBackend) agents(p *project.Project) settings.Section { + sec := settings.Section{Title: "Agents", Icon: "◆"} + for _, a := range hooks.All() { + it := settings.Item{Key: "agent:" + a.ID(), Label: a.Name(), Kind: settings.Toggle} + var notes []string + if _, err := exec.LookPath(a.Binary()); err == nil { + notes = append(notes, a.Binary()+" is on PATH.") + } else { + notes = append(notes, a.Binary()+" is not on PATH here.") + } + if p == nil { + it.Disabled = needInit + } else { + switch a.State(p.Root) { + case hooks.Current: + it.On = true + case hooks.Outdated: + it.On, it.Value = true, "hook outdated: toggle off and on" + default: + if !a.Detect(p.Root) { + it.Value = "not detected" + } + } + if mw, ok := mcpconfig.For(a.ID()); ok && it.On { + if mw.State(p.Root) == hooks.Current { + notes = append(notes, "Memory server entry in "+strings.TrimPrefix(mw.Path(p.Root), p.Root+"/")+".") + } else if it.Value == "" { + it.Value = "no memory entry" + } + } + } + if n, ok := a.(hooks.Noter); ok { + notes = append(notes, n.AddNote()) + } + it.Detail = "Capture " + a.Name() + " sessions run in its own UI, and give it shared memory. " + strings.Join(notes, " ") + sec.Items = append(sec.Items, it) + } + all := settings.Item{Key: "agents:all", Label: "Add all detected agents", Kind: settings.Action, + Detail: "Hook every agent found on this machine or configured in this folder."} + if p == nil { + all.Disabled = needInit + } + sec.Items = append(sec.Items, all) + return sec +} + +func (b *settingsBackend) sync(ctx context.Context, p *project.Project) settings.Section { + sec := settings.Section{Title: "Sync", Icon: "⇅"} + if p == nil { + sec.Items = []settings.Item{{Key: "sync", Label: "Background sync", Kind: settings.Toggle, Disabled: needInit}} + return sec + } + on, line := syncState(p.Root) + sec.Items = append(sec.Items, settings.Item{Key: "sync", Label: "Background sync", Kind: settings.Toggle, On: on, Value: line, + Detail: "Keeps atlas serve running for this Project: hooks hand it each prompt, tool call and reply, and it sends them to the cloud at once. Your agents' hooks restart it after a reboot. Same as atlas sync on|off."}) + if st, err := store.Open(p.Root, false); err == nil { + if c, err := st.OutboxCounts(ctx); err == nil { + sec.Items = append(sec.Items, settings.Item{Label: "Outbox", Value: fmt.Sprintf("%d pending, %d failed", c[store.SyncPending], c[store.SyncFailed])}) + } + _ = st.Close() + } + sec.Items = append(sec.Items, + settings.Item{Key: "sync:now", Label: "Sync now", Kind: settings.Action, Detail: "Send what is pending to the cloud Project now (atlas capture sync)."}, + settings.Item{Key: "sync:retry", Label: "Retry failed", Kind: settings.Action, Detail: "Re-queue rows that failed to sync, then sync (atlas capture retry)."}, + ) + if e := strings.TrimSpace(hooks.LastError(p.Root)); e != "" { + sec.Items = append(sec.Items, settings.Item{Label: "Last hook error", Value: e, Detail: e}) + } + return sec +} + +// model is the local MiniLM model memory search runs on. +func (b *settingsBackend) model(p *project.Project) settings.Section { + sec := settings.Section{Title: "Model"} + have := embed.Available() + state, search := "not downloaded", "keywords only" + if have { + state = fmt.Sprintf("downloaded · %.0f MB", float64(embed.DiskSize())/1e6) + search = "meaning and keywords" + } + sec.Items = []settings.Item{ + {Label: "Memory model", Value: embed.ModelID + " · " + state, + Detail: "A small local model (MiniLM, 384 dimensions) that turns memories and documents into vectors, so memory_search finds them by meaning. It runs on this machine; nothing is sent anywhere."}, + {Label: "Memory search", Value: search}, + {Label: "Location", Value: embed.ModelDir(), Detail: "The Atlas app keeps its models here too: one download serves both."}, + } + dl := settings.Item{Key: "model:download", Label: "Download model", Kind: settings.Action, Value: embed.Size, + Detail: "Download " + embed.Repo + " (" + embed.Size + ", from Hugging Face, once), then embed this Project's memories and index its documents."} + if have { + dl.Disabled = "The model is already downloaded." + } + sec.Items = append(sec.Items, dl) + idx := settings.Item{Key: "memory:index", Label: "Build memory index", Kind: settings.Action, + Detail: "Embed every memory and index the project's documents now (atlas memory index). Hooks and atlas serve also keep it current."} + if p == nil { + idx.Disabled = needInit + } + sec.Items = append(sec.Items, idx) + if have { + sec.Items = append(sec.Items, settings.Item{Key: "model:remove", Label: "Remove model", Kind: settings.Action, + Confirm: "This deletes the model files the Atlas app also uses.", + Detail: "Delete the model files to free " + embed.Size + ". Memory search falls back to keywords. The Atlas app uses the same files and would download them again."}) + } + return sec +} + +func (b *settingsBackend) about(p *project.Project) settings.Section { + sec := settings.Section{Title: "About", Icon: "◇", Items: []settings.Item{ + {Label: "Version", Value: version}, + {Key: "update:check", Label: "Check for updates", Kind: settings.Action, Detail: "Ask GitHub for the latest release (atlas update --check)."}, + {Key: "update", Label: "Update atlas", Kind: settings.Action, + Detail: "Download the latest release for this machine, verify it against the release's checksums.txt, and replace this binary (atlas update). A background listener is restarted on the new version."}, + }} + if p != nil { + sec.Items = append(sec.Items, + settings.Item{Label: "CLI state", Value: clistate.Dir(p.Root), Detail: "Hook state, logs and the listener's run file; .atlas/ holds only what the app writes."}, + settings.Item{Label: "Hooks log", Value: hooks.LogPath(p.Root)}, + ) + } + return sec +} + +// ApplyProgress runs the long actions with a progress report: the model +// download and the index; everything else is Apply. +func (b *settingsBackend) ApplyProgress(ctx context.Context, key string, on bool, report func(float64, string)) (string, error) { + p := b.project(ctx) + switch key { + case "model:download": + if err := embed.Download(ctx, func(done, total int64) { + if total > 0 { + report(0.9*float64(done)/float64(total), fmt.Sprintf("%.0f of %.0f MB", float64(done)/1e6, float64(total)/1e6)) + } + }); err != nil { + return "", fmt.Errorf("download the model: %w", err) + } + msg := "Model downloaded: memory search now uses meaning and keywords." + if p != nil { + report(0.95, "embedding memories") + if m, err := indexMemory(ctx, p.Root, p.ScopeRoot, false, func(string) {}, func(float64) {}); err == nil { + msg += " " + m + } + } + report(1, "") + return msg, nil + case "memory:index": + if p == nil { + return "", errNoProject + } + return indexMemory(ctx, p.Root, p.ScopeRoot, true, func(s string) { report(-1, s) }, func(f float64) { report(f, "") }) + } + return b.Apply(ctx, key, on) +} + +// Apply runs the command behind a row and returns its last line. +func (b *settingsBackend) Apply(ctx context.Context, key string, on bool) (string, error) { + p := b.project(ctx) + switch { + case key == "model:remove": + if err := embed.Remove(); err != nil { + return "", err + } + return "Model removed: memory search uses keywords until it is downloaded again.", nil + case key == "update:check": + return applyUpdate(ctx, func(string, string) {}, "", true) + case key == "update": + return applyUpdate(ctx, func(string, string) {}, "", false) + case key == "capture": + return quietly(ctx, runCaptureGate(on)) + case key == "memory": + return quietly(ctx, runMemoryGate(on)) + case p == nil: + return "", errNoProject + case key == "sync": + return setBackgroundSync(p.Root, on) + case key == "memory:index": + return indexMemory(ctx, p.Root, p.ScopeRoot, true, func(string) {}, func(float64) {}) + case key == "sync:now": + return quietly(ctx, runCaptureSync(false)) + case key == "sync:retry": + return quietly(ctx, runCaptureSync(true)) + case key == "agents:all": + var detected []hooks.Agent + for _, a := range hooks.All() { + if a.Detect(p.Root) { + detected = append(detected, a) + } + } + if len(detected) == 0 { + return "No supported agent was found on this machine.", nil + } + return quietly(ctx, func(c *cobra.Command, _ []string) error { + if err := installAgents(ctx, c.OutOrStdout(), p.Root, detected, false, false); err != nil { + return err + } + fmt.Fprintf(c.OutOrStdout(), "Hooked %d agent(s).\n", len(detected)) + return nil + }) + case strings.HasPrefix(key, "agent:"): + a, ok := hooks.Get(strings.TrimPrefix(key, "agent:")) + if !ok { + return "", fmt.Errorf("unknown agent %s", key) + } + return quietly(ctx, func(c *cobra.Command, _ []string) error { + if on { + return installAgents(ctx, c.OutOrStdout(), p.Root, []hooks.Agent{a}, true, false) + } + return removeAgents(ctx, c.OutOrStdout(), p.Root, []hooks.Agent{a}) + }) + } + return "", fmt.Errorf("unknown setting %s", key) +} + +// quietly runs a command's body with its output captured, and returns the +// most telling line: the first one that is not a note. +func quietly(ctx context.Context, run func(*cobra.Command, []string) error) (string, error) { + var buf bytes.Buffer + c := &cobra.Command{} + c.SetOut(&buf) + c.SetErr(&buf) + c.SetContext(ctx) + err := run(c, nil) + var last string + for _, l := range strings.Split(buf.String(), "\n") { + if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "note:") { + last = l + } + } + return last, err +} diff --git a/cmd/atlas/settings_cmds_test.go b/cmd/atlas/settings_cmds_test.go new file mode 100644 index 0000000..176645e --- /dev/null +++ b/cmd/atlas/settings_cmds_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tryatlas/atlas-cli/internal/clistate" + "github.com/tryatlas/atlas-cli/internal/hooks" +) + +func TestInitHooksTheAgentsGiven(t *testing.T) { + root := gitRepo(t) + code, out, e := runCmd(t, "init", "--local", "--agents", "claude-code,codex") + if code != 0 { + t.Fatalf("exit %d: %s", code, e) + } + for _, id := range []string{"claude-code", "codex"} { + a, _ := hooks.Get(id) + if a.State(root) != hooks.Current { + t.Fatalf("%s not hooked:\n%s", id, out) + } + } + if a, _ := hooks.Get("cursor"); a.State(root) != hooks.Absent { + t.Fatal("cursor was not asked for") + } + if _, err := os.Stat(filepath.Join(root, ".mcp.json")); err != nil { + t.Fatal("memory entry missing") + } + if code, _, e := runCmd(t, "init", "--local", "--yes", "--agents", "nope"); code != 1 || !strings.Contains(e, "nope is not a supported agent") { + t.Fatalf("exit %d: %s", code, e) + } +} + +func TestInitWithoutAgentFlagsHooksNothingWhenNotInteractive(t *testing.T) { + root := gitRepo(t) + if code, _, e := runCmd(t, "init", "--local"); code != 0 { + t.Fatal(e) + } + for _, a := range hooks.All() { + if a.State(root) != hooks.Absent { + t.Fatalf("%s hooked without being asked", a.ID()) + } + } +} + +func TestSyncOffAndStatus(t *testing.T) { + root := gitRepo(t) + if code, out, e := runCmd(t, "sync"); code != 1 || !strings.Contains(e+out, "init") { + t.Fatalf("exit %d: %s %s", code, out, e) + } + _, _, _ = runCmd(t, "init", "--local") + code, out, e := runCmd(t, "sync") + if code != 0 || !strings.Contains(out, "background sync off") { + t.Fatalf("exit %d: %s %s", code, out, e) + } + _ = clistate.SavePrefs(root, clistate.Prefs{BackgroundSync: true}) + if _, out, _ := runCmd(t, "sync", "status"); !strings.Contains(out, "on (not running now") { + t.Fatal(out) + } + if code, out, _ := runCmd(t, "sync", "off"); code != 0 || !strings.Contains(out, "Background sync is off.") { + t.Fatal(out) + } + if clistate.LoadPrefs(root).BackgroundSync { + t.Fatal("off must be remembered") + } + if code, _, _ := runCmd(t, "sync", "maybe"); code != 1 { + t.Fatal("only on, off and status") + } +} + +func TestSettingsPrintsWithoutATerminal(t *testing.T) { + gitRepo(t) + _, _, _ = runCmd(t, "init", "--local", "--agents", "pi") + code, out, e := runCmd(t, "settings") + if code != 0 { + t.Fatalf("exit %d: %s", code, e) + } + for _, want := range []string{"Account", "Project", "Session capture", "on", "Shared memory", "Agents", "Pi ", "Sync", "Background sync", "About", "Version"} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q:\n%s", want, out) + } + } +} + +func TestSettingsBackendAppliesThroughTheCommands(t *testing.T) { + root := gitRepo(t) + _, _, _ = runCmd(t, "init", "--local") + be := &settingsBackend{cwd: root} + ctx := t.Context() + if msg, err := be.Apply(ctx, "agent:opencode", true); err != nil || !strings.Contains(msg, "OpenCode") { + t.Fatalf("%q %v", msg, err) + } + if a, _ := hooks.Get("opencode"); a.State(root) != hooks.Current { + t.Fatal("not installed") + } + if msg, err := be.Apply(ctx, "agent:opencode", false); err != nil || !strings.Contains(msg, "removed") { + t.Fatalf("%q %v", msg, err) + } + if msg, err := be.Apply(ctx, "capture", false); err != nil || !strings.Contains(msg, "paused") { + t.Fatalf("%q %v", msg, err) + } + if msg, err := be.Apply(ctx, "memory", false); err != nil || !strings.Contains(msg, "off") { + t.Fatalf("%q %v", msg, err) + } + secs, _ := be.Load(ctx) + for _, s := range secs { + for _, it := range s.Items { + if (it.Key == "capture" || it.Key == "memory") && it.On { + t.Fatalf("%s still on", it.Key) + } + } + } +} + +func TestInitCreatesTheMemoryRecord(t *testing.T) { + root := gitRepo(t) + if code, _, e := runCmd(t, "init", "--local"); code != 0 { + t.Fatal(e) + } + if _, err := os.Stat(filepath.Join(root, ".atlas", "memory", "memory.sqlite")); err != nil { + t.Fatalf("no memory record after init: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".atlas", ".gitignore")); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/atlas/status_view.go b/cmd/atlas/status_view.go new file mode 100644 index 0000000..3356e73 --- /dev/null +++ b/cmd/atlas/status_view.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/tryatlas/atlas-cli/internal/tui" +) + +// statusGroups puts atlas status's lines under the card's sections. +var statusGroups = []struct { + title string + labels []string +}{ + {"Account", []string{"Account", "Org"}}, + {"Project", []string{"Project", "Binding", "Memory at", "Memory", "Capture", "Recorder"}}, + {"Capture and sync", []string{"Sessions", "Commits", "Sync", "Record"}}, + {"Agents", []string{"Listener", "Agents", "Git hook", "Hooks", "Hook err"}}, +} + +// statusOf reads a value's first words for its dot. +func statusOf(label, value string) tui.State { + v := strings.ToLower(value) + for _, zero := range []string{"0 failed", "0 pending", "0 need attention"} { + v = strings.ReplaceAll(v, " "+zero, "") + } + has := func(words ...string) bool { + for _, w := range words { + if strings.Contains(v, w) { + return true + } + } + return false + } + switch { + case label == "Hook err" || has("failed", "broken", "refused", "unreadable", "error"): + return tui.Bad + case has("need attention", "outdated", "spooled", "not installed", "left open", "pending"): + return tui.Warn + case strings.HasPrefix(v, "off") || strings.HasPrefix(v, "paused") || strings.HasPrefix(v, "none") || strings.HasPrefix(v, "not ") || has("not signed in", "not initialised", "sync off"): + return tui.Idle + case strings.HasPrefix(v, "on") || strings.HasPrefix(v, "ok") || strings.HasPrefix(v, "idle") || has("intact", "linked up to", "sync on"): + return tui.Good + } + return tui.Neutral +} + +// renderStatus draws status lines as a card: the header, then a section per +// group, each value with its dot. +func renderStatus(w io.Writer, lines string) { + values := map[string]string{} + var order []string + for _, l := range strings.Split(strings.TrimRight(lines, "\n"), "\n") { + label, value, ok := strings.Cut(l, ":") + if !ok || strings.HasPrefix(l, " ") { + if len(order) > 0 { // a continuation of the previous value + values[order[len(order)-1]] += " " + strings.TrimSpace(l) + } + continue + } + label = strings.TrimSpace(label) + values[label] = strings.TrimSpace(value) + order = append(order, label) + } + tui.Header(w, "status") + seen := map[string]bool{} + for _, g := range statusGroups { + var has bool + for _, l := range g.labels { + if _, ok := values[l]; ok { + has = true + } + } + if !has { + continue + } + tui.Section(w, g.title) + for _, l := range g.labels { + v, ok := values[l] + if !ok { + continue + } + seen[l] = true + tui.Field(w, statusLabel(l), tui.Dot(statusOf(l, v), v)) + } + } + for _, l := range order { + if !seen[l] { + tui.Field(w, statusLabel(l), values[l]) + } + } + fmt.Fprintln(w) +} + +func statusLabel(l string) string { + switch l { + case "Org": + return "Organisation" + case "Hook err": + return "Last hook error" + } + return l +} diff --git a/cmd/atlas/status_view_test.go b/cmd/atlas/status_view_test.go new file mode 100644 index 0000000..8b042ac --- /dev/null +++ b/cmd/atlas/status_view_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + + "github.com/tryatlas/atlas-cli/internal/tui" +) + +func TestStatusDots(t *testing.T) { + for _, c := range []struct { + label, value string + want tui.State + }{ + {"Sync", "ok · 1183 sent, 0 pending, 0 failed", tui.Good}, + {"Sync", "ok · 10 sent, 3 pending, 0 failed", tui.Warn}, + {"Sync", "offline · 2 failed", tui.Bad}, + {"Listener", "background sync off (sessions sync as each hook fires)", tui.Idle}, + {"Listener", "background sync on (listener pid 1)", tui.Good}, + {"Capture", "paused", tui.Idle}, + {"Sessions", "3 session(s), 1 turn(s) left open", tui.Warn}, + {"Commits", "linked up to eaa9ab0", tui.Good}, + {"Project", "/Users/x/repo", tui.Neutral}, + } { + if got := statusOf(c.label, c.value); got != c.want { + t.Errorf("%s %q = %v, want %v", c.label, c.value, got, c.want) + } + } +} diff --git a/cmd/atlas/sync_cmds.go b/cmd/atlas/sync_cmds.go new file mode 100644 index 0000000..ad2829f --- /dev/null +++ b/cmd/atlas/sync_cmds.go @@ -0,0 +1,143 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/tryatlas/atlas-cli/internal/clistate" + "github.com/tryatlas/atlas-cli/internal/listener" + "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui" +) + +// atlas sync [on|off]: background sync is a running atlas serve for the +// Project. on starts it and keeps it wanted, so an agent's next hook +// restarts it after a reboot; off stops it and forgets it. +func newSyncCmd() *cobra.Command { + return withRun(&cobra.Command{ + Use: "sync [on|off]", + Short: "Turn background sync on or off for this Project (a detached atlas serve), or show it", + Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"on", "off", "status"}, + }, runSync) +} + +func runSync(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + cwd, err := os.Getwd() + if err != nil { + return err + } + p, err := project.Discover(cmd.Context(), cwd) + if errors.Is(err, project.ErrNotInitialised) { + return errNoProject + } + if err != nil { + return err + } + switch { + case len(args) == 0 || args[0] == "status": + if interactive() { + on, note := syncState(p.Root) + tui.Header(out, "sync") + tui.Section(out, "Background sync") + if on { + tui.Field(out, "State", tui.Dot(tui.Good, "on")) + } else { + tui.Field(out, "State", tui.Dot(tui.Idle, "off")) + } + tui.Field(out, "Listener", note) + fmt.Fprintln(out) + tui.Note(out, "atlas sync on · atlas sync off") + return nil + } + printSync(out, p.Root) + return nil + case args[0] == "on": + return tui.Work(out, "Starting background sync", func(tui.StepFunc) (string, error) { + return setBackgroundSync(p.Root, true) + }) + default: + return tui.Work(out, "Stopping background sync", func(tui.StepFunc) (string, error) { + return setBackgroundSync(p.Root, false) + }) + } +} + +// setBackgroundSync records the choice and starts or stops the listener. +func setBackgroundSync(root string, on bool) (string, error) { + prefs := clistate.LoadPrefs(root) + prefs.BackgroundSync = on + if err := clistate.SavePrefs(root, prefs); err != nil { + return "", err + } + if !on { + pid, err := haltListener(root) + if err != nil { + return "", err + } + if pid == 0 { + return "Background sync is off.", nil + } + return fmt.Sprintf("Background sync is off (stopped pid %d).", pid), nil + } + if rf := listener.ReadRunFile(root); rf != nil { + return fmt.Sprintf("Background sync is on (already running, pid %d).", rf.PID), nil + } + pid, port, err := launchListener(root, 0) + if err != nil { + return "", err + } + return fmt.Sprintf("Background sync is on: listener pid %d on 127.0.0.1:%d.", pid, port), nil +} + +// syncState says whether background sync is on, and a note on the listener. +func syncState(root string) (on bool, note string) { + want := clistate.LoadPrefs(root).BackgroundSync + rf := listener.ReadRunFile(root) + switch { + case rf != nil && rf.Daemon: + return true, fmt.Sprintf("listener pid %d on 127.0.0.1:%d since %s", rf.PID, rf.Port, rf.StartedAt) + case rf != nil: + return want, fmt.Sprintf("a foreground listener runs (pid %d)", rf.PID) + case want: + return true, "not running now; the next agent hook starts it" + } + return false, "sessions sync as each hook fires" +} + +func printSync(w io.Writer, root string) { + on, note := syncState(root) + state := "off" + if on { + state = "on" + } + fmt.Fprintf(w, "Listener: background sync %s (%s)\n", state, note) +} + +// listenerRetry spaces the hooks' attempts to restart a wanted listener +// (the desktop may hold the store, and then serve cannot start). +const listenerRetry = time.Minute + +// ensureListener restarts the background listener from an agent hook when +// the user turned background sync on and none runs. It never waits. +func ensureListener(root string) { + if !clistate.LoadPrefs(root).BackgroundSync || listener.ReadRunFile(root) != nil { + return + } + mark := clistate.Path(root, "serve.spawn") + if fi, err := os.Stat(mark); err == nil && time.Since(fi.ModTime()) < listenerRetry { + return + } + _ = os.WriteFile(mark, nil, 0o600) + now := time.Now() + _ = os.Chtimes(mark, now, now) + if c, err := spawnListener(root, 0); err == nil { + _ = c.Process.Release() + } +} diff --git a/cmd/atlas/update_cmds.go b/cmd/atlas/update_cmds.go new file mode 100644 index 0000000..769e5d1 --- /dev/null +++ b/cmd/atlas/update_cmds.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "fmt" + "os" + "runtime" + + "github.com/spf13/cobra" + + "github.com/tryatlas/atlas-cli/internal/listener" + "github.com/tryatlas/atlas-cli/internal/project" + "github.com/tryatlas/atlas-cli/internal/tui" + "github.com/tryatlas/atlas-cli/internal/update" +) + +// Seams replaced in tests. +var ( + updateSource = update.GitHub + updateTarget = update.Executable +) + +// atlas update: replace this binary with the latest release (or --version), +// verified against the release's checksums.txt like the installer does. +func newUpdateCmd() *cobra.Command { + c := withRun(leaf("update", "Update atlas to the latest release (verified against the release checksums)", cobra.NoArgs), runUpdate) + c.Flags().Bool("check", false, "only say whether a newer release exists") + c.Flags().String("version", "", "install this release tag instead of the latest (e.g. v0.3.4)") + return c +} + +func runUpdate(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + out := cmd.OutOrStdout() + check, _ := cmd.Flags().GetBool("check") + want, _ := cmd.Flags().GetString("version") + if want != "" && want[0] != 'v' { + want = "v" + want + } + if !interactive() { + msg, err := applyUpdate(ctx, func(label, value string) { fmt.Fprintf(out, "%-10s %s\n", label+":", value) }, want, check) + if err == nil { + fmt.Fprintln(out, msg) + } + return err + } + title := "Updating atlas" + if check { + title = "Checking for a newer atlas" + } + return tui.Work(out, title, func(step tui.StepFunc) (string, error) { + return applyUpdate(ctx, func(label, value string) { step(label + " " + value) }, want, check) + }) +} + +// applyUpdate does the work and returns the closing line; each step is +// reported to step as a label and a value. +func applyUpdate(ctx context.Context, step func(label, value string), want string, check bool) (string, error) { + src := updateSource() + tag := want + if tag == "" { + step("Checking", "the latest release on GitHub") + latest, err := src.Latest(ctx) + if err != nil { + return "", fmt.Errorf("could not read the latest release: %w", err) + } + tag = latest + } + step("Installed", version) + step("Release", tag) + if want == "" && !update.Newer(version, tag) { + return fmt.Sprintf("atlas %s is up to date.", version), nil + } + if check { + return fmt.Sprintf("atlas %s is available; run atlas update.", tag), nil + } + exe, err := updateTarget() + if err != nil { + return "", err + } + step("Downloading", update.Archive(tag, runtime.GOOS, runtime.GOARCH)) + bin, err := src.Download(ctx, tag) + if err != nil { + return "", err + } + step("Verified", update.Archive(tag, runtime.GOOS, runtime.GOARCH)+" (sha256)") + if err := update.Replace(exe, bin); err != nil { + return "", fmt.Errorf("%w (reinstall with the installer, or run atlas update with write access to %s)", err, exe) + } + step("Replaced", exe) + if note := restartListener(ctx); note != "" { + step("Listener", note) + } + return fmt.Sprintf("Updated atlas %s → %s.", version, tag), nil +} + +// restartListener puts this Project's background listener on the new +// binary; a foreground one is left to its owner. +func restartListener(ctx context.Context) string { + cwd, err := os.Getwd() + if err != nil { + return "" + } + p, err := project.Discover(ctx, cwd) + if err != nil { + return "" + } + rf := listener.ReadRunFile(p.Root) + if rf == nil { + return "" + } + if !rf.Daemon { + return "a foreground atlas serve still runs the old version; restart it" + } + if _, err := haltListener(p.Root); err != nil { + return "could not stop the old listener: " + err.Error() + } + pid, _, err := launchListener(p.Root, 0) + if err != nil { + return "restart failed: " + err.Error() + } + return fmt.Sprintf("restarted on the new version (pid %d)", pid) +} diff --git a/cmd/atlas/update_cmds_test.go b/cmd/atlas/update_cmds_test.go new file mode 100644 index 0000000..8dfd1f3 --- /dev/null +++ b/cmd/atlas/update_cmds_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/tryatlas/atlas-cli/internal/update" +) + +func fakeRelease(t *testing.T, tag string, bin []byte) { + t.Helper() + var arch bytes.Buffer + zw := gzip.NewWriter(&arch) + tw := tar.NewWriter(zw) + _ = tw.WriteHeader(&tar.Header{Name: "atlas", Mode: 0o755, Size: int64(len(bin)), Typeflag: tar.TypeReg}) + _, _ = tw.Write(bin) + _ = tw.Close() + _ = zw.Close() + sum := sha256.Sum256(arch.Bytes()) + name := update.Archive(tag, runtime.GOOS, runtime.GOARCH) + mux := http.NewServeMux() + mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintf(w, `{"tag_name":%q}`, tag) }) + mux.HandleFunc("/dl/", func(w http.ResponseWriter, r *http.Request) { + switch filepath.Base(r.URL.Path) { + case "checksums.txt": + fmt.Fprintf(w, "%s %s\n", hex.EncodeToString(sum[:]), name) + case name: + _, _ = w.Write(arch.Bytes()) + default: + http.NotFound(w, r) + } + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + old := updateSource + updateSource = func() update.Source { + return update.Source{API: srv.URL + "/api", Base: srv.URL + "/dl", HTTP: srv.Client()} + } + t.Cleanup(func() { updateSource = old }) +} + +func TestUpdate(t *testing.T) { + fakeRelease(t, "v9.0.0", []byte("atlas 9")) + exe := filepath.Join(t.TempDir(), "atlas") + _ = os.WriteFile(exe, []byte("atlas old"), 0o755) + oldTarget, oldVersion := updateTarget, version + updateTarget = func() (string, error) { return exe, nil } + t.Cleanup(func() { updateTarget, version = oldTarget, oldVersion }) + version = "0.4.0" + + code, out, e := runCmd(t, "update", "--check") + if code != 0 || !strings.Contains(out, "atlas v9.0.0 is available") { + t.Fatalf("exit %d: %s %s", code, out, e) + } + if b, _ := os.ReadFile(exe); string(b) != "atlas old" { + t.Fatal("--check must not replace") + } + code, out, e = runCmd(t, "update") + if code != 0 || !strings.Contains(out, "Updated atlas 0.4.0 → v9.0.0.") || !strings.Contains(out, "Verified:") { + t.Fatalf("exit %d: %s %s", code, out, e) + } + if b, _ := os.ReadFile(exe); string(b) != "atlas 9" { + t.Fatalf("binary = %q", b) + } + version = "9.0.0" + if code, out, _ := runCmd(t, "update"); code != 0 || !strings.Contains(out, "atlas 9.0.0 is up to date.") { + t.Fatal(out) + } + if code, _, e := runCmd(t, "update", "--version", "8.0.0"); code != 1 || !strings.Contains(e, "atlas_8.0.0_") { + t.Fatalf("a missing release must fail: %d %s", code, e) + } +} diff --git a/docs/RELEASE.md b/docs/RELEASE.md index e47ce3d..c9d1f98 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -55,7 +55,7 @@ Run them before tagging. Record the results in `docs/progress.md`. | 4 | `atlas agents list` | The eight agents; hook and memory "absent"; binaries on PATH named | | 5 | `atlas agents add claude-code` | "hook installed, memory server added to .mcp.json"; the git post-commit hook installed; `.claude/settings.local.json` holds six Atlas hooks | | 6 | `claude -p "Create hello.md with one line"` | The reply; `hello.md` exists; `atlas status` shows 1 session; `atlas agents sessions` lists it with `claude -r ` | -| 7 | `claude` (its own UI): a prompt that edits a file, then exit | The SessionStart banner "Atlas: capture on · memory on · …"; a second turn in the store with the tool call and the file touch | +| 7 | `claude` (its own UI): a prompt that edits a file and runs a command, then exit | The SessionStart banner "Atlas: capture on · memory on · …"; while the agent still works, `sqlite3 .atlas/sessions.db "select tool_name from tool_call"` already lists the calls (per-event); after Stop one completed turn with the prompt first, one row per call, the reply last, no `changes-N` call | | 8 | `atlas memory list` | `file_changed` entries from both turns | | 9 | Commit the file | The post-commit hook links it: `atlas status` says "Commits: linked up to " without running `atlas capture link` | | 10 | `git commit --amend -m reworded`, then `atlas capture link` | "Rewritten history: 1 relinked" | diff --git a/docs/adr/0013-hooks-capture-native-agent-uis.md b/docs/adr/0013-hooks-capture-native-agent-uis.md index 152a408..0f8ccdf 100644 --- a/docs/adr/0013-hooks-capture-native-agent-uis.md +++ b/docs/adr/0013-hooks-capture-native-agent-uis.md @@ -51,7 +51,7 @@ the product. Commits are linked by the existing walk (`atlas capture link`), now also run from a `post-commit` git hook. No git refs, no commit trailers. 4. **Hooks never fail the agent.** Every hook exits 0; errors go to - `.atlas/cli-hooks.log` and the last one shows in `atlas status`. Stdout + `hooks.log` in the CLI state directory and the last one shows in `atlas status`. Stdout carries only the agent's own response shape (the SessionStart banner). Every hook runs under a deadline. The installed command is wrapped in `sh -c 'if ! command -v atlas >/dev/null 2>&1; then exit 0; fi; exec atlas @@ -59,7 +59,7 @@ the product. 5. **Delivery.** A hook writes to the store itself when it can take the writer lock; when `atlas serve` holds it, the hook posts the turn to the listener's owner-only Unix socket; when the desktop app holds it, the turn - is spooled under `.atlas/hooks/spool/` and drained by the next writer. + is spooled in the CLI state directory (`hooks/spool/`) and drained by the next writer. 6. **Shared memory reaches native UIs through MCP config.** `atlas agents add` also writes the `atlas_memory` server entry into the agent's MCP config as a stdio entry `atlas mcp-bridge --project --agent ` @@ -75,9 +75,21 @@ the product. group until their removal is agreed, and are no longer documented as the product. +9. **Per event, not per turn** (added 2026-10-12). The prompt opens the turn + in the store as it is submitted; each tool call is recorded as the + agent's post-tool hook fires (Claude Code, Codex, Copilot, Droid and + Antigravity have a generic one; Cursor's file-edit and shell hooks, + OpenCode's plugin and Pi's extension carry the same information); the + turn closes at Stop with the transcript's text and usage, and transcript + tool items that duplicate a delivered call are dropped (same id, + arguments, shell command or written paths). Each delivery syncs. Token + streaming is out of reach for hooks. + ## Consequences -- Native-UI sessions get file touches and checkpoints again, live. +- Native-UI sessions get file touches and checkpoints again, live: the + prompt and each tool call reach the cloud within seconds; the reply at + turn end. - One integration per agent is the price; the external-agent protocol is a later addition. - A hook-captured turn's file changes are attributed to the tool that wrote diff --git a/docs/assets/banner.png b/docs/assets/banner.png new file mode 100644 index 0000000..4f289c4 Binary files /dev/null and b/docs/assets/banner.png differ diff --git a/docs/progress.md b/docs/progress.md index 30a5787..af57922 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1183,3 +1183,395 @@ https://github.com/tryatlas/atlas-cli/releases/tag/v0.2.0, four archives, `checksums.txt`, `install.sh`. CI and the release workflow were green on all four native runners. The anonymous installer delivers a build with the hooks commands. + +## v0.2.1: a turn reaches the cloud right after its hook + +A hook that writes the store directly now drains the new rows to the cloud +Project within the drain's 3 s budget (signed in, cloud-bound; offline or +rate-limited rows stay pending for the next drain). A turn delivered to +`atlas serve` forces a drain instead of waiting for the 30 s tick. Before, +without `atlas serve` nothing synced until `atlas capture sync`. + +## Per-event capture (ADR-0013 §9, 2026-10-12) + +The prompt opens the turn in the store as it is submitted; each tool call is +recorded as the agent's post-tool hook fires (with the file it wrote); Stop +closes the turn with the transcript's text runs and usage. Transcript tool +items that duplicate a delivered call are dropped (same id, canonical name + +arguments hash, shell command text, or every written path), and the files a +delivered call wrote count as explained, so no synthetic "changes" call is +made for them. Every delivery syncs: `atlas serve` forces a drain per event; +a direct write drains within the sync budget (1 s for a post-tool hook, 3 s +otherwise). A post-tool hook costs 60–180 ms locally. + +Verified live with Claude Code: `claude -p` with Write + Bash, directly and +through `atlas serve`: one completed turn, the prompt row first, one tool +row per call in order, the reply last, one file touch, no duplicates. + +Found by the per-agent live re-tests and fixed in the core: + +- Hooks of one session can fire concurrently (Pi runs six tool handlers at + once): the dispatcher now holds a per-session flock for the hook's + lifetime and writes state through a per-process temp name. +- Delivered calls are tracked as key groups, each matching at most one + transcript item; when an agent's hook ids match its transcript ids (Claude, + Pi, OpenCode) ids decide alone, so two identical `sleep 4` calls stay two + calls; otherwise arguments (never empty `{}`), command text (string or + argv) or every written path decide (Codex, Copilot, Cursor). +- A shell call's hook diffs git status right then: the files the command + changed are its touches, delivered with the call, and the baseline moves + on; no synthetic "changes" call for them. +- A delivery carries the hook's own time, so a spooled one keeps it. + +Per-agent per-event results: Claude Code, Codex (code-mode `exec` scripts +unwrapped to the inner `apply_patch`/`exec_command` so hook and rollout +match), Copilot (no call ids in its payload; a derived id, matching by +arguments, command or path), OpenCode (plugin `tool.execute.after`, ids +equal the export's `callID`), Pi (`tool_result`, ids equal the session +file's), Droid (PostToolUse added; untested live), Cursor (`afterFileEdit` +and `afterShellExecution` become Write/Edit and Shell calls, matched by +written path and command text; the TUI asks before MCP tools and +non-allowlisted commands), Antigravity (PostToolUse carries no output and +the id is derived from the planner step; the prompt is read from the +transcript's pending USER_INPUT at PreInvocation so the turn opens). A +transcript item matched by id completes the delivered row's result and +status without sampling its writes again. Ordering within a turn is prompt, +tool calls as they ran, then the reply's text runs at the end: the text is +known only at Stop. + +## v0.3.0 + +Per-event capture for all eight agents (above), the per-session lock, the +dedupe by key groups, shell changes on the shell call, hook times kept. + +## v0.3.1: commits made by an agent get their checkpoint + +Reported: agents commit but no checkpoint appears. Two causes, both fixed: + +- A file written and committed by one shell command never shows in `git + status`, so the turn had no touch for it. The dispatcher now remembers + HEAD at turn start (and after each shell call); files changed by commits + made during the turn count as the turn's changes, on the shell call that + committed them. +- The post-commit hook links only touches that already exist; touches that + land later (at Stop, or a shell call's) missed the commit, and the cursor + had moved past it. A delivery now carries the turn's starting HEAD, and the + writer links the commits made since then (`gitlink.LinkRange`), consuming + the late touches so they cannot attach to a later commit. + +Verified live: `claude -p` running only `echo … > c4.txt && git add && git +commit` → checkpoint `["c4.txt"]` linked, the touch consumed by that commit; +`codex exec` the same → checkpoint `["c5.txt"]` linked. + +## v0.3.2: `.atlas/` holds only what the desktop app writes + +Reported: the CLI's `.atlas/` differed from the app's. Compared against +app-made folders on this machine (`Delta`, `acp-ref`, `Portfolio-Clean`): +the shared files (`.gitignore`, `project.json`, `sessions.db`, +`sessions.lock`, `memory/memory.sqlite`) were already identical; the CLI also +wrote its own `hooks/` (session state, spool, OpenCode export cache, locks), +`cli-hooks.log`, `cli-run.json`, `cli-serve.log` and `cli-import.json`. Those +now live in a per-user state directory, `$XDG_STATE_HOME/atlas-cli/projects/ +<16 hex of sha256(root)>/` (default `~/.local/state/…`, 0700, with a `root` +file naming the Project): `hooks/`, `hooks.log`, `run.json` (0600), +`serve.log`, `import.json`. Older layouts are moved there on first use. The +app's own UI and index files (`editor-state.json`, `logs.jsonl`, +`codebase-index/`, `memory/extract-state`, …) are the app's to create when it +opens the folder. Every test package now points `XDG_STATE_HOME` at a temp +directory. + +Verified: `test-cli` migrated in place (history kept); a fresh Project after +a hooked Claude turn and an `atlas serve` start/stop has `.atlas/` = +`.gitignore memory project.json sessions.db sessions.lock`. + +## v0.3.3: terminal sessions feed memory like app sessions + +Reported: app memory and terminal memory differ. Both use the same file +(`.atlas/memory/memory.sqlite`, schema v6) and in this repo `atlas memory +list` matched the app's active entries; the difference was in what each +side writes for a session. Fixed: + +- **Agent ids.** (Reverted in 0.3.4 at the user's request: agents keep + their own names.) +- **Capture candidates.** Lines of the agent's reply carrying the app's + markers (`decided to`, `decision:`, `gotcha:`, `doesn't work`, `note:`, + `architecture:`, …) become candidates (source `capture`, 0.3). +- **Handoff note.** At session end the episode is written in the app's + HandoffNote shape from the session's active memories, and rewritten after + the extractor's final pass. +- **Extractor.** After a turn (≥ 20 turns and ≥ 3 tool calls since the last + pass) and at session end, a detached `atlas hooks extract` asks the Atlas + gateway's default model (`/v1/models` `default: true`, glm-5.3-flash today) + to distil decisions, facts, failures, architecture and preferences; entries + land as source `extractor`. Signed in only; the summariser's `local` mode + sends nothing; one pass per session at a time. The excerpt comes from the + capture store, which is already redacted. +- `atlas memory list --all` includes candidates, as the app's panel does. + +Verified live (`claude -p` in a fresh Project): `file_changed`, two capture +candidates, three extractor memories ~40 s later, the handoff with the +distilled decision and fact, everything as `claude-acp`; in this repo +`atlas memory list --all` = 202 = the app's store. + +## v0.3.4: agents keep their own names + +At the user's request the agent renaming of v0.3.3 is reverted: hook +sessions, memory entries, handoff notes and the MCP bridge use the +integration's own id (`claude-code`, `codex`, `cursor`, `copilot`, +`opencode`, `droid`, `antigravity`, `pi`). Candidates, handoff notes and the +extractor stay. Sessions recorded by 0.3.3 keep the names they were written +with. + +## v0.4.0: guided init, `atlas sync`, `atlas settings` + +At the user's request ("init with an all-selection option: org, project, +agents with an add-all option, sync daemon on/off as `atlas sync`; a properly +designed Bubble Tea UI and an `atlas settings` screen"): + +- **`atlas init` is a four-step flow** on a terminal (`tui.StepSelect`, + `tui.StepMulti`): Organisation, Project (the fingerprint match first and + marked, the org's other Projects, "+ Create a new Project"), Agents (a + checklist of the agents detected here, all pre-checked, with an + **All agents** row; `a`/`n` select all or none), Background sync (On/Off). + Each answered step collapses to a `✓ n. Step answer` line; esc leaves a + `✗`. Long lists scroll. The binding is written and the store released + before step 3, so the listener can take it. `--local` asks step 3 only. + New flags: `--agents`, `--all-agents`, `--sync on|off`; without a + terminal and without them, init hooks nothing (as before). A signed-out + `atlas init` on a terminal offers "Sign in" or "Set up locally". +- **`atlas sync [on|off|status]`**: on starts a detached `atlas serve` and + records `backgroundSync` in `prefs.json` in the CLI state directory; off + stops it and clears it. While it is on, a SessionStart or TurnStart hook + restarts a missing listener (after its own delivery, at most once a + minute: the desktop may hold the store). `atlas status` shows a + `Listener:` line. `atlas serve --daemon/--stop` stay as the low-level + form. +- **`atlas settings`**: a full-screen Bubble Tea screen with a sidebar + (Account, Project, Agents, Sync, About) and a detail pane. Toggles: + session capture, shared memory, each agent (install/remove hook and + memory entry), background sync; actions: add all detected agents, sync + now, retry failed. Changes run through the same code as the commands, off + the UI loop, with a spinner and a status line. Without a terminal it + prints the rows. +- `installAgents` / `removeAgents` are shared by `atlas agents`, init and + settings; `launchListener` / `haltListener` by serve, sync and settings. +- Verified live in a pty: init steps 1–3 against the real account + (cancelled before connecting a Project), the agent checklist installing + only the picked agents, the settings screen toggling an agent, and + `atlas sync on` → kill -9 → a Claude SessionStart hook restarting the + listener. +- **Fix: a commit of a file the agent had read got no checkpoint.** The + dispatcher treats the files a tool call names as "explained", so git's + view of the shell call that follows does not count them twice. Read and + Search calls name files too (OpenCode's read metadata, Claude's + `file_path`), so after "read greet.js, then git add && git commit" the + commit's change was dropped as explained: no touch, no checkpoint. Now + only calls that write (Edit, Write, Delete, Move, or carrying a diff) + explain a path (`writtenPaths`). Test: + `TestCommittingAFileWrittenBeforeTheTurnGetsItsCheckpoint` (fails before + the fix). Verified live with OpenCode and Claude Code: read, then commit, + gives a linked checkpoint with the file. +- **TUI redesign after `Tui-design-ins/`** (the user's references: a + breadcrumb over a rule and a `[KEY] label` footer; Oxide's tree of + answered steps with green answers and a block progress bar; Cursor's + ☒ ▣ ☐ checklist and ⬢ status; a mail client's titled panels, `` key + badges and coloured tags; a highlight bar on the selected row). Shared + pieces in `internal/tui/frame.go` (`Crumb`, `Rule`, `Keys`, `Badge`, + `Tag`, `Bar`, `TreeLine`, `Upper`, `Panel`, `Fit`, `Wrap`, `Indent`), + colours in the theme (ANSI indices, so the terminal's palette decides). + - `atlas init`: each step is `atlas / setup / `, `STEP n OF 4` + with a ▮▯ bar, the question, options on the highlight bar, the key + footer; an answered step becomes `├ Step answer` and the run ends + `└ ⬢ Atlas is ready in …`; install output stays on the tree's stem + (wrapped at 76). Lists fit the terminal and scroll. A cancelled step + ends `└ Step cancelled` with no second error line. + - `atlas settings`: Sections, rows and Details panels (two panels under + 110 columns), `<1>`–`<5>` badges with icons, ON/OFF/RUN tags, a + spinner while a change applies, `⬢ result` or `✗ error` in the footer. + - `atlas memory browse`: the same frame, kind tabs on the highlight bar, + Entries and Entry panels, upper-case field labels. + Checked by rendering the real screens to PNG (pyte + Pillow) at 100 and + 120 columns. +- **`atlas update [--check] [--version vX.Y.Z]`** (`internal/update`): reads + the latest release from the GitHub API (or takes the tag given), downloads + `atlas___.tar.gz` and `checksums.txt` as install.sh does, + refuses a checksum mismatch, extracts `atlas`, and renames it over the + running binary (resolved through symlinks; a temp file next to it, mode + kept). A development build (`0.4.0-dev`) counts as older than its + release. A background listener for the Project in the cwd is restarted + on the new binary. `ATLAS_UPDATE_BASE_URL` replaces the download base. + Settings → About has "Check for updates" and "Update atlas". Verified + live: a 0.3.0 build updated itself to v0.3.4 from GitHub. +- **Atlas look across every command** (the user: "Bubble Tea animations, + but no gradients; our Atlas design colours; our logo; every command with + a TUI"). From the desktop app (`crates/atlas-theme/themes/atlas.toml`, + `src/assets/atlas-icon.svg`): + - **Palette** (`tui/theme.go`): the app's hex colours, dark and light; + monochrome first, the selection an inversion (white bar, black text), + colour only for status (success #9ecf8a, warning #ffff00, error + #f44747, info #6796e6), the logo cream #FFFFE2. No gradients: every + animation switches between two tones. Light or dark from + `ATLAS_THEME=light|dark`, else `COLORFGBG`, else dark (the terminal is + not queried: one that does not answer would hang). Colours are cut + down to the terminal's profile (`tui.Out`). + - **Logo** (`tui/logo.go`): the mark's exact dot grid (three stripes, 4 + dots wide, 14 tall, one column right every two rows): `LogoSmall` a 5-row `●●●` miniature (braille was dropped: some fonts + draw its empty dots, which turned the logo into noise), `Mark` + `▟▛ ▟▛ ▟▛` (block quadrants, a slanted bar in any font), `Wordmark`. Reveal animation lights the stripes one + by one from the bottom; the working spinner pulses the three stripes. + - **Every command on a terminal**: `atlas` alone shows the splash (logo + lighting up, commands by group); `--help` is styled; `login` is a + screen with the code boxed, the URL and a live expiry countdown; + `whoami`, `org list`, `agents list|sessions`, `memory list` are + sections and tables with status dots; `status` is a card grouped + Account / Project / Capture and sync / Agents with dots; `logout`, + `org create`, `capture sync|retry|link`, `memory verify|export|import`, + `sync on|off`, `update` run under the pulsing mark with live steps and + end `✓ …` or `✗ …`; `serve` in the foreground is a live screen with a + feed of hook events (prompt, tool, reply, commit linked); `init` opens + with the logo; settings carries the logo in its sidebar. Pipes and + scripts get the same plain text as before (all tests unchanged). + `ATLAS_NO_ANIMATION=1` skips the reveal. +- **The logo is Array Bold's `///`** (Fontshare's Array, as the user pointed out; checked against the font's own "/" glyph: round dots, 4 wide, 14 tall, one column right every two rows). `tui.Logo` keeps every dot column and puts two dot rows in each text row, so the slope and the 1.7:1 proportions hold: 7 rows × 24 columns, three stripes 3 columns apart. It is the only logo (splash, `init` intro, settings sidebar); the 5-row miniature and the braille version are gone. +- Headers read `Atlas settings / account` (no mark); the settings sidebar has no logo. The logo is on the splash and the `init` intro only. +- **Tree lists and a real progress bar** (the user's references: Oxide's + `├ Title Colossal Cave Adventure` tree and its green `▮▮▮▯▯▯` bar). + Lists (init's options, settings sections and rows) are trees: a gold + `├`/`└` (the app's terminal yellow #e5c07b), the label, the value in + green; the selected row is `▸` on the app's selection grey (#303030), not + a white bar; badges and icons are gone. Values read `on` (green), `off` + (dim), `run ↵` (gold). Waiting is a progress bar (green `▮` over `▯`) + under a bold title with the elapsed time: it eases toward 90% while the + work runs, steps on as each step reports (shown as tree lines), and fills + before `✓ …`. Init's last phase runs as "Initializing Atlas in " + under it, each agent installed a step. + +## Memory parity with the app's .atlas/memory (and the "lost memory" bug) + +- **Bug: a listener kept writing into a deleted store.** In test-cli the user + deleted `.atlas/` and ran `atlas init` again while a background `atlas + serve` (started earlier) still ran. It held the deleted `sessions.db`, + `sessions.lock` and `memory.sqlite` open, so every hook delivered into + files no one could see: no sessions, no `memory/` folder. Now the host + records the store it opened and `Host.Stale()` compares it with what is + on disk (`os.SameFile`); the listener checks every 2 s and stops, and + refuses deliveries meanwhile, so hooks write the new store directly and + the next one restarts the listener (background sync on). The memory + server reopens a record whose file was replaced (`record.Store.Stale`), + which also covers a long-lived `mcp-bridge`. Verified live: re-init under + a running listener, then a Claude session: the session and its memory + land in the new store, a new listener starts. +- `atlas init` creates `.atlas/memory/memory.sqlite` when shared memory is + on (the app creates it on the first session; the folder shows from the + start). +- Extractor progress is the desktop's file and format: + `.atlas/memory/extract-state/.json` with + `last_extracted_turn_index`, `tool_calls_since_last`, `extraction_count` + (written atomically; the CLI's old state file is read once and removed; + the pass lock stays in the CLI state dir). A session not in the store is + skipped, not logged as an error (the "sql: no rows" hook errors). +- `memory.snapshot.sqlite`: the desktop's daily backup (`VACUUM INTO`, a + `snapshot_meta` 'at' stamp, rename), refreshed at session start at most + once a day. A damaged record (quick_check, malformed) is quarantined as + `memory.sqlite.corrupt-`, the snapshot copied in, and `restored.json` + written, as `RecordStore::open` does; a record declined for its schema + version is never quarantined. +- Not done, by design or pending a decision: `corpus.sqlite` and its + `.usearch` (the "documents" leg of memory_search, MiniLM embeddings via + candle in the app), the record's `embed_cache` (dense search), the + `memory-index/policy.json` cache (the app's Policy view only), and + `code-index/` (the atlas_code tools). + +## Memory search like the app: embeddings, corpus, hybrid memory_search + +The user: "those are important for shared memory" (the corpus, the vectors). +Everything below is byte-compatible with the desktop, so either side keeps +what the other wrote. + +- **`internal/embed`: MiniLM in pure Go** (no cgo). BERT WordPiece tokenizer + from `tokenizer.json` (BertNormalizer: clean text, CJK spacing, accent + strip, lowercase; BertPreTokenizer; WordPiece `##`, [UNK], 100 chars; added + tokens matched first; truncation 512 with [CLS]/[SEP], as atlas-embed + overrides it), a 6-layer encoder over `model.safetensors` (float32, erf + GELU, LayerNorm 1e-12), mean pooling, L2 norm. It reads the model the app + downloads (`/models/all-MiniLM-L6-v2/`; `ATLAS_MODELS_DIR` + overrides) and downloads it there the app's way (`.part`, rename) when + asked. Checked against the app: 54 entries of the atlas-cli record hit + the app's own cache keys, every Go vector at cosine 1.000000 to the app's. + ~55 ms a memory on this machine. +- **Record vectors** (`memory.sqlite` `embed_cache`): key blake3(model ‖ 0 ‖ + text), model `all-MiniLM-L6-v2-384`, f16 LE (768 bytes). A durable + `Remember` is embedded before its transaction; a write that matches no key + or text merges into the closest same-kind memory at cosine ≥ 0.92 unless + both are keyed or the texts contradict (numbers, negation: + `ContradictsText`, the app's consolidate rule). `SyncVectors` backfills + and drops garbage keys; `Forget` purges the vectors of every text the + entry had. +- **Hybrid search** (`record.Search`, the app's search_explained): BM25 leg + and meaning leg (cosine ≥ 0.35, 100 deep, exact over the cache) fused by + RRF k 60, priors trust 0.5 and recency 0.3 over max(last use, update); + `why` reports `bm25` and `dense` ranks. No model: keyword only. +- **Corpus** (`internal/memory/corpus`, `.atlas/memory/corpus.sqlite`): the + app's schema; docs from the project's CLAUDE.md and AGENTS.md (mirrored + block and injected context stripped), `.atlas/knowledge` notes, active + durable memories with a log position (caps 50/30/30/50), and captured + sessions of agents without a dedicated reader (12 KiB of role lines). + Content hash sha256(title\n\ntext), vkey from blake3(id), cache key + blake3(model ‖ 0 ‖ hash hex), title/body split, user/repo scope; changed + docs are re-embedded, `vectors_dirty:` set so the app rebuilds its + usearch file. The CLI never reads agent folders (Claude's memory dir, + ~/.claude, ~/.codex) and never deletes the documents the app reads from + them (`Owned`). Checked against the app: all 9 documents both produce for + atlas-cli have the app's content hash. +- **memory_search documents**: when no kinds are given, up to 6 (limit, + max 20) from the corpus: dense (cosine ≥ 0.30) and BM25, pool max(4·limit, + 20), RRF, Jaccard ≥ 0.8 dedup, a forgotten memory's doc filtered out; the + query embedded untrimmed. `memory_forget` evicts the doc at once. +- **When it runs**: `atlas memory index` (downloads the model unless + `--no-download`, progress bar), after a session ends (detached, never + downloads), after an extraction stores memories, every 15 min in + `atlas serve`, and `atlas init` asks to download the model (a "Memory + search" step) when it is missing. Settings shows "Memory search: meaning + and keywords" and a "Build memory index" action. +- Verified live: a Claude session asked `memory_search` "how do we say hi to + people" and got both greeting memories by meaning (`why: {dense: 1}`, + `{dense: 2}`) and the CLAUDE.md rule and the shared decision as documents. +- Still the app's alone: `memory-index/policy.json` (the Policy view's + cache), `code-index/` (atlas_code tools), the global memory blend. +- **Settings ▸ Model**: the local MiniLM model's state (downloaded and its + size, or not), what memory search uses (meaning and keywords, or keywords + only), where the files live (shared with the app), and actions: Download + model (87 MB from Hugging Face, the bar and "n of 87 MB" in the row, then + the Project's memories embedded and documents indexed), Build memory + index, Remove model (asks for a second enter: the app uses the same + files). Settings actions can now report progress (`settings.Progresser`) + and ask for confirmation (`Item.Confirm`). The embedder notices a model + downloaded in the same process. Verified live into a scratch + ATLAS_MODELS_DIR: the files match the app's byte for byte in size. +- **Plans from agents run in their own UIs.** Only the parked ACP path + folded plans (`plan_set`); hook capture never did, so the Plan tab stayed + empty. Now the agents' plan tools are read as they complete and at turn + end: Claude Code's and Droid's `TodoWrite`, Codex's `update_plan`, + OpenCode's `todowrite`, Cursor's `todo_write`, Copilot's `update_todo` + (a list or a Markdown checklist), Claude Code's `ExitPlanMode` (the plan + as Markdown), and Claude Code's incremental `TaskCreate` / `TaskUpdate` + (the list kept in the hook session state, each call applied once by its + id, replayed from the transcript when its hook did not deliver it). The + plan is the ACP text ("- [status] item", 600 chars) and is `done` once + every item is completed; each new plan supersedes the last. + `memory_briefing` gives it to the next session as the active plan. + Verified live with OpenCode (`todowrite` → the plan entry). + +## v0.4.0 release + +PR #3 (`feat/tui-memory-search` → main) carries everything since #2 was +merged at v0.2.0: v0.3.x and the v0.4.0 work above. Before tagging: tests, +lint and `go mod tidy -diff` clean; `make release-check` and `make snapshot` +built the four archives and `checksums.txt`, and install.sh installed the +snapshot from them. README redesigned with the banner +(`docs/assets/banner.png`). +- The first v0.4.0 tag's release run failed on Linux: ext4 gave a + re-created `sessions.db` the deleted file's inode number, so the inode + comparison missed a replaced store. The host and the memory record now + also hold a handle on the file they opened and treat a link count of 0 + (unlinked) as stale. The tag was moved to the fixed commit. diff --git a/go.mod b/go.mod index 287555d..0274bf3 100644 --- a/go.mod +++ b/go.mod @@ -6,19 +6,20 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.9 + github.com/charmbracelet/x/term v0.2.2 github.com/pelletier/go-toml/v2 v2.4.3 github.com/spf13/cobra v1.10.2 github.com/zeebo/blake3 v0.2.4 golang.org/x/sys v0.49.0 + golang.org/x/text v0.42.0 modernc.org/sqlite v1.60.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect - github.com/charmbracelet/x/ansi v0.11.9 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/go.sum b/go.sum index 3083341..9d6b8e8 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agents/acp/state_main_test.go b/internal/agents/acp/state_main_test.go new file mode 100644 index 0000000..d91d467 --- /dev/null +++ b/internal/agents/acp/state_main_test.go @@ -0,0 +1,15 @@ +package acp + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/auth/state_main_test.go b/internal/auth/state_main_test.go new file mode 100644 index 0000000..3528e37 --- /dev/null +++ b/internal/auth/state_main_test.go @@ -0,0 +1,15 @@ +package auth + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/capture/git/link.go b/internal/capture/git/link.go index 1246bc5..86fd8a5 100644 --- a/internal/capture/git/link.go +++ b/internal/capture/git/link.go @@ -74,7 +74,7 @@ func Link(ctx context.Context, st *store.Store, root string, now func() time.Tim return res, err } for _, sha := range strings.Fields(list) { - n, err := evaluate(ctx, g, st, sha, touches, now) + n, err := evaluate(ctx, g, st, sha, touches, now, false) if err != nil { return res, err } @@ -99,8 +99,9 @@ type commit struct { } // evaluate links one first-parent commit; a merge creates no checkpoint and -// its side commits are evaluated instead. -func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touches []store.Touch, now func() time.Time) (int, error) { +// its side commits are evaluated instead. late says the touches may have been +// recorded after the commit (LinkRange): they are consumed all the same. +func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touches []store.Touch, now func() time.Time, late bool) (int, error) { c, err := g.commit(ctx, sha) if err != nil { return 0, err @@ -112,7 +113,7 @@ func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touche } total := 0 for _, s := range strings.Fields(side) { - n, err := evaluate(ctx, g, st, s, touches, now) + n, err := evaluate(ctx, g, st, s, touches, now, late) if err != nil { return total, err } @@ -161,8 +162,12 @@ func evaluate(ctx context.Context, g runner, st *store.Store, sha string, touche if err := st.UpsertCheckpoint(ctx, cp, now()); err != nil { return written, err } + cutoff := c.time + if late { + cutoff = now() + } for _, p := range touchPaths { - if err := st.ConsumeTouches(ctx, sid, p, sha, c.time); err != nil { + if err := st.ConsumeTouches(ctx, sid, p, sha, cutoff); err != nil { return written, err } } @@ -356,3 +361,37 @@ func (g runner) branch(ctx context.Context) *string { } return &b } + +// LinkRange links the first-parent commits after from up to HEAD to the +// touches recorded so far, whatever the cursor says: an agent hook calls it +// when touches arrive after a commit the post-commit hook already walked (a +// file written and committed in one shell command, or a turn delivered at +// its end). Checkpoints are upserted, so a commit linked twice stays one. +func LinkRange(ctx context.Context, st *store.Store, root, from string, now func() time.Time) (int, error) { + g := runner{dir: root} + if from == "" || !g.isCommit(ctx, from) || g.midOperation(ctx) { + return 0, nil + } + list, err := g.out(ctx, "rev-list", "--first-parent", "--reverse", "--max-count", strconv.Itoa(scanLimit), from+"..HEAD") + if err != nil || list == "" { + return 0, err + } + touches, err := st.LiveTouches(ctx, root) + if err != nil || len(touches) == 0 { + return 0, err + } + total := 0 + for _, sha := range strings.Fields(list) { + n, err := evaluate(ctx, g, st, sha, touches, now, true) + if err != nil { + return total, err + } + total += n + if n > 0 { + if touches, err = st.LiveTouches(ctx, root); err != nil { + return total, err + } + } + } + return total, nil +} diff --git a/internal/capture/git/state_main_test.go b/internal/capture/git/state_main_test.go new file mode 100644 index 0000000..ef609f6 --- /dev/null +++ b/internal/capture/git/state_main_test.go @@ -0,0 +1,15 @@ +package git + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/capture/record/recorder.go b/internal/capture/record/recorder.go index 07f84f2..ac98b38 100644 --- a/internal/capture/record/recorder.go +++ b/internal/capture/record/recorder.go @@ -221,6 +221,9 @@ type Tool struct { Binary []byte Locations []string Diffs []Diff // ACP diff content blocks + // NoSample says the call's writes were already sampled (a hook delivered + // it): this sighting only completes the row (result, status). + NoSample bool `json:"no_sample,omitempty"` } // ToolCall upserts the call; on its first completed sighting it samples the diff --git a/internal/capture/record/state_main_test.go b/internal/capture/record/state_main_test.go new file mode 100644 index 0000000..de38293 --- /dev/null +++ b/internal/capture/record/state_main_test.go @@ -0,0 +1,15 @@ +package record + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/capture/record/turn.go b/internal/capture/record/turn.go index 34963fe..eff22d0 100644 --- a/internal/capture/record/turn.go +++ b/internal/capture/record/turn.go @@ -22,7 +22,11 @@ import ( type CapturedTurn struct { Prompt string `json:"prompt"` Items []CapturedItem `json:"items,omitempty"` - Model string `json:"model,omitempty"` + // Open says the turn was opened earlier by OpenTurn (and may already + // hold tool calls from RecordTool): complete it instead of starting a + // new one. + Open bool `json:"open,omitempty"` + Model string `json:"model,omitempty"` // Usage is the turn's token usage when the transcript or the hook gave // one; nil records nothing. Usage *store.TokenTotals `json:"usage,omitempty"` @@ -58,9 +62,11 @@ func (r *Recorder) RecordTurn(ctx context.Context, s Session, t CapturedTurn) (i if at.IsZero() { at = r.Now() } - l.turn++ - if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil { - return 0, err + if !t.Open || l.turn == 0 { + l.turn++ + if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil { + return 0, err + } } stripped := StripUser(t.Prompt) if strings.TrimSpace(stripped) != "" { @@ -132,25 +138,8 @@ func (r *Recorder) RecordTurn(ctx context.Context, s Session, t CapturedTurn) (i } call = id } - inHead := headFiles(r.Root) - for _, p := range t.Changed { - ft := store.FileTouch{ToolCallID: call, SessionID: l.id, TurnSeq: l.turn, Path: p, ExistedBefore: inHead[p]} - if filepath.IsAbs(p) { - ft.OutOfRepo = true - } - abs := p - if !ft.OutOfRepo { - abs = filepath.Join(r.Root, filepath.FromSlash(p)) - } - if data, err := os.ReadFile(abs); err == nil { //nolint:gosec // G304: a path git reported changed - h := store.SHA256Hex(data) - ft.SHA256After, ft.SketchAfter = &h, Sketch(data) - } else { - ft.Deleted = true - } - if err := r.Store.AddFileTouch(ctx, ft, at); err != nil { - return l.turn, err - } + if err := r.touchPaths(ctx, l, call, t.Changed, at); err != nil { + return l.turn, err } } if t.Usage != nil { @@ -222,7 +211,7 @@ func (r *Recorder) recordCapturedTool(ctx context.Context, l *live, t Tool, at t if err != nil { return "", name, err } - if status == "completed" && (WritesFiles(name) || len(t.Diffs) > 0) && !l.touched[t.NativeID] { + if status == "completed" && !t.NoSample && (WritesFiles(name) || len(t.Diffs) > 0) && !l.touched[t.NativeID] { l.touched[t.NativeID] = true if err := r.sampleWrites(ctx, l, id, t.NativeID, WritePaths(t.Arguments, t.Locations, t.Diffs...), t.Arguments, t.Diffs, at); err != nil { return id, name, err @@ -230,3 +219,96 @@ func (r *Recorder) recordCapturedTool(ctx context.Context, l *live, t Tool, at t } return id, name, nil } + +// OpenTurn starts a turn as the prompt is submitted (the per-event path): the +// turn row, the title on the first turn, and the user message. The rest of +// the turn arrives through RecordTool and a CapturedTurn with Open set. +func (r *Recorder) OpenTurn(ctx context.Context, s Session, prompt string, at time.Time) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + if !r.enabled(ctx) { + return 0, nil + } + l, err := r.session(ctx, s) + if err != nil { + return 0, err + } + if at.IsZero() { + at = r.Now() + } + l.turn++ + if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil { + return 0, err + } + stripped := StripUser(prompt) + if strings.TrimSpace(stripped) == "" { + return l.turn, nil + } + if flat, err := redact.Flat(stripped); err == nil { + if title := Title(flat.Text); title != "" { + _ = r.Store.SetTitleIfAbsent(ctx, l.id, title) + } + } + body, ok := r.redactAuto(ctx, l, stripped) + if !ok { + return l.turn, nil + } + _, err = r.Store.RecordMessage(ctx, store.Message{SessionID: l.id, TurnSeq: l.turn, + NativeID: fmt.Sprintf("prompt-%d-%s", l.turn, store.SHA256Hex([]byte(prompt))), + Role: "user", Mode: "text", Body: body, CreatedAt: at}) + return l.turn, err +} + +// RecordTool upserts one tool call into the session's open turn as the +// agent's post-tool hook reports it, sampling the files it wrote; changed +// are the Project paths git saw change while a shell call ran, recorded as +// its touches. With no open turn it opens one. +func (r *Recorder) RecordTool(ctx context.Context, s Session, t Tool, changed []string, at time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + if !r.enabled(ctx) { + return nil + } + l, err := r.session(ctx, s) + if err != nil { + return err + } + if at.IsZero() { + at = r.Now() + } + if l.turn == 0 { + l.turn++ + if err := r.Store.BeginTurn(ctx, l.id, l.turn, at); err != nil { + return err + } + } + id, _, err := r.recordCapturedTool(ctx, l, t, at) + if err != nil || id == "" || len(changed) == 0 { + return err + } + return r.touchPaths(ctx, l, id, changed, at) +} + +// touchPaths records file touches for paths changed outside a known write. +func (r *Recorder) touchPaths(ctx context.Context, l *live, callID string, paths []string, at time.Time) error { + inHead := headFiles(r.Root) + for _, p := range paths { + ft := store.FileTouch{ToolCallID: callID, SessionID: l.id, TurnSeq: l.turn, Path: p, ExistedBefore: inHead[p]} + abs := p + if filepath.IsAbs(p) { + ft.OutOfRepo = true + } else { + abs = filepath.Join(r.Root, filepath.FromSlash(p)) + } + if data, err := os.ReadFile(abs); err == nil { //nolint:gosec // G304: a path git reported changed + h := store.SHA256Hex(data) + ft.SHA256After, ft.SketchAfter = &h, Sketch(data) + } else { + ft.Deleted = true + } + if err := r.Store.AddFileTouch(ctx, ft, at); err != nil { + return err + } + } + return nil +} diff --git a/internal/capture/store/state_main_test.go b/internal/capture/store/state_main_test.go new file mode 100644 index 0000000..add354c --- /dev/null +++ b/internal/capture/store/state_main_test.go @@ -0,0 +1,15 @@ +package store + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/capture/sync/state_main_test.go b/internal/capture/sync/state_main_test.go new file mode 100644 index 0000000..d243224 --- /dev/null +++ b/internal/capture/sync/state_main_test.go @@ -0,0 +1,15 @@ +package sync + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/clistate/clistate.go b/internal/clistate/clistate.go new file mode 100644 index 0000000..23835c4 --- /dev/null +++ b/internal/clistate/clistate.go @@ -0,0 +1,80 @@ +// Package clistate is where the CLI keeps its own per-Project files: hook +// session state, the spool, the listener's run file and logs. They live +// outside /.atlas/ so that folder holds exactly what the desktop app +// writes: $XDG_STATE_HOME/atlas-cli/projects/<16 hex of sha256(root)>/ +// (default ~/.local/state/…), with a "root" file naming the Project. +package clistate + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "os" + "path/filepath" +) + +// Dir is the CLI's state directory for a Project root. +func Dir(root string) string { + base := os.Getenv("XDG_STATE_HOME") + if !filepath.IsAbs(base) { + home, err := os.UserHomeDir() + if err != nil { + home = os.TempDir() + } + base = filepath.Join(home, ".local", "state") + } + sum := sha256.Sum256([]byte(root)) + return filepath.Join(base, "atlas-cli", "projects", hex.EncodeToString(sum[:])[:16]) +} + +// Ensure creates the directory (0700: it holds typed prompts) and its root +// file, and moves what older CLIs left in /.atlas/. +func Ensure(root string) (string, error) { + d := Dir(root) + if err := os.MkdirAll(d, 0o700); err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(d, "root")); errors.Is(err, fs.ErrNotExist) { + _ = os.WriteFile(filepath.Join(d, "root"), []byte(root+"\n"), 0o600) + } + migrate(root, d) + return d, nil +} + +// Path is a file in the state directory (created on demand). +func Path(root, name string) string { + d, err := Ensure(root) + if err != nil { + return filepath.Join(Dir(root), name) + } + return filepath.Join(d, name) +} + +// legacy maps what CLIs up to 0.3.1 wrote into .atlas/ to its new name. +var legacy = map[string]string{ + "hooks": "hooks", + "cli-hooks.log": "hooks.log", + "cli-serve.log": "serve.log", + "cli-import.json": "import.json", +} + +func migrate(root, d string) { + old := filepath.Join(root, ".atlas") + for from, to := range legacy { + src := filepath.Join(old, from) + if _, err := os.Lstat(src); err != nil { + continue + } + dst := filepath.Join(d, to) + if _, err := os.Lstat(dst); err == nil { + _ = os.RemoveAll(src) // the new copy wins + continue + } + if os.Rename(src, dst) != nil { + _ = os.RemoveAll(src) + } + } + // A run file of a listener from an older CLI is stale by now. + _ = os.Remove(filepath.Join(old, "cli-run.json")) +} diff --git a/internal/clistate/prefs.go b/internal/clistate/prefs.go new file mode 100644 index 0000000..fa210b6 --- /dev/null +++ b/internal/clistate/prefs.go @@ -0,0 +1,41 @@ +package clistate + +import ( + "encoding/json" + "fmt" + "os" +) + +// Prefs are the CLI's per-Project choices that are not the desktop's +// (.atlas/ stays the app's). +type Prefs struct { + // BackgroundSync keeps atlas serve running for the Project: atlas sync on + // starts it, and an agent hook restarts it when it is gone. + BackgroundSync bool `json:"backgroundSync"` +} + +const prefsFile = "prefs.json" + +// LoadPrefs reads the Project's prefs; a missing or unreadable file is the +// zero value. +func LoadPrefs(root string) Prefs { + var p Prefs + if b, err := os.ReadFile(Path(root, prefsFile)); err == nil { + _ = json.Unmarshal(b, &p) + } + return p +} + +// SavePrefs writes them. +func SavePrefs(root string, p Prefs) error { + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + path := Path(root, prefsFile) + tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/embed/app_compat_test.go b/internal/embed/app_compat_test.go new file mode 100644 index 0000000..18e75c9 --- /dev/null +++ b/internal/embed/app_compat_test.go @@ -0,0 +1,74 @@ +package embed + +import ( + "database/sql" + "os" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// TestMatchesTheAppsVectors embeds entries of a real app-written record and +// compares them with the vectors the desktop cached for the same text. It +// runs only where both exist (ATLAS_COMPAT_RECORD names the memory.sqlite). +func TestMatchesTheAppsVectors(t *testing.T) { + path := os.Getenv("ATLAS_COMPAT_RECORD") + if path == "" || !Available() { + t.Skip("set ATLAS_COMPAT_RECORD to an app-written memory.sqlite; needs the model") + } + e, err := Default() + if err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + t.Fatal(err) + } + defer db.Close() + rows, err := db.Query(`SELECT content FROM entries ORDER BY id DESC LIMIT 60`) + if err != nil { + t.Fatal(err) + } + var contents []string + for rows.Next() { + var c string + _ = rows.Scan(&c) + contents = append(contents, c) + } + _ = rows.Close() + matched, worst := 0, float32(1) + start := time.Now() + for _, c := range contents { + var vec []byte + if db.QueryRow(`SELECT vec FROM embed_cache WHERE key = ?`, CacheKey(ModelName, c)).Scan(&vec) != nil { + continue + } + matched++ + cos := Cosine(e.Embed(c), Decode(vec)) + worst = min(worst, cos) + if cos < 0.999 { + t.Errorf("cosine %.5f for %q", cos, c) + } + // Our f16 bytes should be nearly identical to the app's. + if b := Encode(e.Embed(c)); len(b) != len(vec) { + t.Fatalf("encoded %d bytes, app %d", len(b), len(vec)) + } + } + if matched == 0 { + t.Fatal("no entry's key matched the app's cache: the key derivation differs") + } + t.Logf("%d entries matched the app's cache keys; worst cosine %.6f; %s", matched, worst, time.Since(start)) +} + +func TestF16RoundTrip(t *testing.T) { + for _, x := range []float32{0, 1, -1, 0.5, 0.1, -0.333, 65504, 1e-5, 6e-8, 3.14159} { + got := f16to32(f32to16(x)) + if d := got - x; d > 0.001*max(1, x) || d < -0.001*max(1, -x) { + t.Errorf("%v → %v", x, got) + } + } + if f32to16(1) != 0x3c00 || f32to16(-2) != 0xc000 || f32to16(0.1) != 0x2e66 { + t.Fatalf("%x %x %x", f32to16(1), f32to16(-2), f32to16(0.1)) + } +} diff --git a/internal/embed/download.go b/internal/embed/download.go new file mode 100644 index 0000000..fcbcf31 --- /dev/null +++ b/internal/embed/download.go @@ -0,0 +1,89 @@ +package embed + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +// BaseURL is where the files come from, as the app downloads them +// (models.rs: https://huggingface.co/{repo}/resolve/main/{file}). +var BaseURL = "https://huggingface.co/" + Repo + "/resolve/main/" + +// Download fetches the files that are missing into ModelDir, each written as +// .part and renamed, as the app does; progress reports bytes done of +// the total known so far. Files already there are kept. +func Download(ctx context.Context, progress func(done, total int64)) error { + dir := ModelDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + client := &http.Client{Timeout: 30 * time.Minute} + var done, total int64 + for _, f := range Files { + dst := filepath.Join(dir, f) + if fi, err := os.Stat(dst); err == nil && fi.Size() > 0 { + continue + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, BaseURL+f, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "atlas-cli") + resp, err := client.Do(req) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return fmt.Errorf("download %s: %s", f, resp.Status) + } + if resp.ContentLength > 0 { + total += resp.ContentLength + } + part := dst + ".part" + out, err := os.Create(part) //nolint:gosec // G304: our own model directory + if err != nil { + _ = resp.Body.Close() + return err + } + buf := make([]byte, 256<<10) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, err := out.Write(buf[:n]); err != nil { + _ = out.Close() + _ = resp.Body.Close() + return err + } + done += int64(n) + if progress != nil { + progress(done, total) + } + } + if rerr == io.EOF { + break + } + if rerr != nil { + _ = out.Close() + _ = resp.Body.Close() + return rerr + } + } + _ = resp.Body.Close() + if err := out.Close(); err != nil { + return err + } + if err := os.Rename(part, dst); err != nil { + return err + } + } + return nil +} + +// Size is the model's download size, for prompts. +const Size = "87 MB" diff --git a/internal/embed/embed.go b/internal/embed/embed.go new file mode 100644 index 0000000..648bc47 --- /dev/null +++ b/internal/embed/embed.go @@ -0,0 +1,263 @@ +// Package embed computes the desktop's memory embeddings in pure Go: +// sentence-transformers/all-MiniLM-L6-v2 (BERT, 6 layers, 384 dimensions), +// mean-pooled over the tokens and L2-normalised, as crates/atlas-embed runs +// it with candle. It reads the model files the app downloads +// (/models/all-MiniLM-L6-v2/{config.json,tokenizer.json, +// model.safetensors}); vectors are cached as the app caches them, f16 +// little-endian under blake3(model ‖ 0 ‖ text), so either side reuses the +// other's. +package embed + +import ( + "encoding/binary" + "errors" + "math" + "os" + "path/filepath" + "runtime" + "sync" + + "github.com/zeebo/blake3" +) + +// The memory model, as the desktop names it. +const ( + ModelID = "all-MiniLM-L6-v2" + Repo = "sentence-transformers/all-MiniLM-L6-v2" + Dim = 384 + ModelName = "all-MiniLM-L6-v2-384" // provider name: "-" +) + +// Files are the model files, in download order. +var Files = []string{"config.json", "tokenizer.json", "model.safetensors"} + +// ErrNoModel says the model files are not on this machine. +var ErrNoModel = errors.New("the memory search model is not downloaded (atlas memory index downloads it)") + +// AppDataDir is the desktop's app data directory (Tauri app_data_dir): +// macOS ~/Library/Application Support/dev.atlas.ide, Linux +// $XDG_DATA_HOME or ~/.local/share, then dev.atlas.ide. +func AppDataDir() string { + home, _ := os.UserHomeDir() + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "dev.atlas.ide") + default: + if x := os.Getenv("XDG_DATA_HOME"); filepath.IsAbs(x) { + return filepath.Join(x, "dev.atlas.ide") + } + return filepath.Join(home, ".local", "share", "dev.atlas.ide") + } +} + +// ModelDir is where the model's files live; ATLAS_MODELS_DIR replaces the +// models directory. +func ModelDir() string { + if d := os.Getenv("ATLAS_MODELS_DIR"); d != "" { + return filepath.Join(d, ModelID) + } + return filepath.Join(AppDataDir(), "models", ModelID) +} + +// Available reports that every model file is present. +func Available() bool { + for _, f := range Files { + if fi, err := os.Stat(filepath.Join(ModelDir(), f)); err != nil || fi.Size() == 0 { + return false + } + } + return true +} + +// Embedder turns text into unit vectors. +type Embedder struct { + tok *Tokenizer + model *Model + mu sync.Mutex +} + +var ( + shared *Embedder + sharedMu sync.Mutex +) + +// Default loads the model once per process; ErrNoModel while it is missing +// (checked again on each call, so a download in this process is picked up). +func Default() (*Embedder, error) { + sharedMu.Lock() + defer sharedMu.Unlock() + if shared != nil { + return shared, nil + } + if !Available() { + return nil, ErrNoModel + } + e, err := Load(ModelDir()) + if err != nil { + return nil, err + } + shared = e + return shared, nil +} + +// Forget drops the loaded model (after its files were removed). +func Forget() { + sharedMu.Lock() + shared = nil + sharedMu.Unlock() +} + +// DiskSize is the bytes the model's files take, 0 when absent. +func DiskSize() int64 { + var n int64 + for _, f := range Files { + if fi, err := os.Stat(filepath.Join(ModelDir(), f)); err == nil { + n += fi.Size() + } + } + return n +} + +// Remove deletes the model's files (the app uses the same ones). +func Remove() error { + Forget() + for _, f := range append(append([]string(nil), Files...), ".metal_incompatible") { + if err := os.Remove(filepath.Join(ModelDir(), f)); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// Load reads a model directory. +func Load(dir string) (*Embedder, error) { + tok, err := LoadTokenizer(filepath.Join(dir, "tokenizer.json")) + if err != nil { + return nil, err + } + m, err := LoadModel(filepath.Join(dir, "config.json"), filepath.Join(dir, "model.safetensors")) + if err != nil { + return nil, err + } + return &Embedder{tok: tok, model: m}, nil +} + +// Embed returns text's unit vector; an empty text is the zero vector. +func (e *Embedder) Embed(text string) []float32 { + e.mu.Lock() + defer e.mu.Unlock() + ids := e.tok.Encode(text) + if len(ids) <= 2 { + return make([]float32, e.model.Dim()) + } + return e.model.Forward(ids) +} + +// CacheKey is blake3(model ‖ 0x00 ‖ text) (atlas-retrieval codec.rs). +func CacheKey(model, text string) []byte { + h := blake3.New() + _, _ = h.Write([]byte(model)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(text)) + return h.Sum(nil) +} + +// VKey is a document's vector key: the first 8 bytes of blake3(id), +// little-endian, as a non-negative int64 (corpus.rs doc_key). +func VKey(id string) int64 { + sum := blake3.Sum256([]byte(id)) + return int64(binary.LittleEndian.Uint64(sum[:8]) & math.MaxInt64) +} + +// Encode packs a vector as f16 little-endian, as the desktop stores it. +func Encode(v []float32) []byte { + out := make([]byte, 2*len(v)) + for i, x := range v { + binary.LittleEndian.PutUint16(out[2*i:], f32to16(x)) + } + return out +} + +// Decode unpacks an f16 little-endian vector. +func Decode(b []byte) []float32 { + out := make([]float32, len(b)/2) + for i := range out { + out[i] = f16to32(binary.LittleEndian.Uint16(b[2*i:])) + } + return out +} + +// Cosine of two vectors (unit vectors: their dot product). +func Cosine(a, b []float32) float32 { + if len(a) != len(b) { + return 0 + } + var dot, na, nb float64 + for i := range a { + dot += float64(a[i]) * float64(b[i]) + na += float64(a[i]) * float64(a[i]) + nb += float64(b[i]) * float64(b[i]) + } + if na == 0 || nb == 0 { + return 0 + } + return float32(dot / math.Sqrt(na*nb)) +} + +// f32to16 rounds to nearest even, as half::f16::from_f32. +func f32to16(f float32) uint16 { + b := math.Float32bits(f) + sign := uint16(b>>16) & 0x8000 + exp := int((b >> 23) & 0xff) + man := b & 0x7fffff + switch { + case exp == 0xff: // inf, NaN + if man != 0 { + return sign | 0x7e00 + } + return sign | 0x7c00 + case exp > 142: // overflow + return sign | 0x7c00 + case exp < 103: // underflow to zero + return sign + case exp < 113: // subnormal + man |= 0x800000 + shift := uint(126 - exp) + half := uint32(1) << (shift - 1) + r := man >> shift + rem := man & ((1 << shift) - 1) + if rem > half || rem == half && r&1 == 1 { + r++ + } + return sign | uint16(r) //nolint:gosec // G115: r < 0x400 here + } + h := uint32(exp-112)<<10 | man>>13 + rem := man & 0x1fff + if rem > 0x1000 || rem == 0x1000 && h&1 == 1 { + h++ + } + return sign | uint16(h) //nolint:gosec // G115: h ≤ 0x7c00 (exp ≤ 142) +} + +func f16to32(h uint16) float32 { + sign := uint32(h&0x8000) << 16 + exp := uint32(h>>10) & 0x1f + man := uint32(h & 0x3ff) + switch exp { + case 0: + if man == 0 { + return math.Float32frombits(sign) + } + // subnormal: normalise + e := uint32(113) + for man&0x400 == 0 { + man <<= 1 + e-- + } + man &= 0x3ff + return math.Float32frombits(sign | e<<23 | man<<13) + case 0x1f: + return math.Float32frombits(sign | 0x7f800000 | man<<13) + } + return math.Float32frombits(sign | (exp+112)<<23 | man<<13) +} diff --git a/internal/embed/model.go b/internal/embed/model.go new file mode 100644 index 0000000..eb3a908 --- /dev/null +++ b/internal/embed/model.go @@ -0,0 +1,310 @@ +package embed + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "math" + "os" + "runtime" + "sync" +) + +// Config is the part of config.json the forward pass needs. +type Config struct { + Hidden int `json:"hidden_size"` + Layers int `json:"num_hidden_layers"` + Heads int `json:"num_attention_heads"` + Intermediate int `json:"intermediate_size"` + MaxPositions int `json:"max_position_embeddings"` + Vocab int `json:"vocab_size"` + Eps float64 `json:"layer_norm_eps"` + Act string `json:"hidden_act"` +} + +type linear struct { + w []float32 // out × in, row-major (PyTorch's layout) + b []float32 + in int + out int +} + +type layerNorm struct{ w, b []float32 } + +type layer struct { + q, k, v, o linear + attnNorm layerNorm + inter, output linear + outNorm layerNorm +} + +// Model is a BERT encoder in float32 (as candle runs it on the CPU). +type Model struct { + cfg Config + word, pos, typ []float32 + embNorm layerNorm + layers []layer +} + +// tensors reads a safetensors file: an 8-byte header length, a JSON header +// of name → {dtype, shape, data_offsets}, then the data. +func tensors(path string) (map[string][]float32, error) { + b, err := os.ReadFile(path) //nolint:gosec // G304: the model's own file + if err != nil { + return nil, err + } + if len(b) < 8 { + return nil, fmt.Errorf("%s: too short", path) + } + n := binary.LittleEndian.Uint64(b[:8]) + if n > uint64(len(b)-8) { //nolint:gosec // G115: len(b) ≥ 8 here + return nil, fmt.Errorf("%s: bad header", path) + } + var hdr map[string]json.RawMessage + if err := json.Unmarshal(b[8:8+n], &hdr); err != nil { + return nil, err + } + data := b[8+n:] + out := map[string][]float32{} + for name, raw := range hdr { + if name == "__metadata__" { + continue + } + var t struct { + Dtype string `json:"dtype"` + Offsets [2]int64 `json:"data_offsets"` + } + if err := json.Unmarshal(raw, &t); err != nil { + return nil, err + } + if t.Dtype != "F32" { + continue // position_ids (I64) and the like + } + lo, hi := t.Offsets[0], t.Offsets[1] + if lo < 0 || hi > int64(len(data)) || lo > hi || (hi-lo)%4 != 0 { + return nil, fmt.Errorf("%s: bad offsets for %s", path, name) + } + f := make([]float32, (hi-lo)/4) + for i := range f { + f[i] = math.Float32frombits(binary.LittleEndian.Uint32(data[lo+int64(i)*4:])) + } + out[name] = f + } + return out, nil +} + +// LoadModel reads config.json and model.safetensors. +func LoadModel(configPath, weightsPath string) (*Model, error) { + cb, err := os.ReadFile(configPath) //nolint:gosec // G304: the model's own file + if err != nil { + return nil, err + } + var cfg Config + if err := json.Unmarshal(cb, &cfg); err != nil { + return nil, err + } + if cfg.Eps == 0 { + cfg.Eps = 1e-12 + } + if cfg.Hidden == 0 || cfg.Heads == 0 || cfg.Hidden%cfg.Heads != 0 { + return nil, fmt.Errorf("config.json: bad sizes") + } + ts, err := tensors(weightsPath) + if err != nil { + return nil, err + } + var missing string + get := func(name string, size int) []float32 { + for _, n := range []string{name, "bert." + name} { + if t, ok := ts[n]; ok && len(t) == size { + return t + } + } + if missing == "" { + missing = name + } + return make([]float32, size) + } + h, in := cfg.Hidden, cfg.Intermediate + lin := func(p string, inDim, outDim int) linear { + return linear{w: get(p+".weight", inDim*outDim), b: get(p+".bias", outDim), in: inDim, out: outDim} + } + ln := func(p string) layerNorm { return layerNorm{w: get(p+".weight", h), b: get(p+".bias", h)} } + m := &Model{cfg: cfg, + word: get("embeddings.word_embeddings.weight", cfg.Vocab*h), + pos: get("embeddings.position_embeddings.weight", cfg.MaxPositions*h), + typ: get("embeddings.token_type_embeddings.weight", 2*h), + embNorm: ln("embeddings.LayerNorm"), + } + for i := range cfg.Layers { + p := fmt.Sprintf("encoder.layer.%d.", i) + m.layers = append(m.layers, layer{ + q: lin(p+"attention.self.query", h, h), k: lin(p+"attention.self.key", h, h), v: lin(p+"attention.self.value", h, h), + o: lin(p+"attention.output.dense", h, h), attnNorm: ln(p + "attention.output.LayerNorm"), + inter: lin(p+"intermediate.dense", h, in), output: lin(p+"output.dense", in, h), outNorm: ln(p + "output.LayerNorm"), + }) + } + if missing != "" { + return nil, fmt.Errorf("model.safetensors: no tensor %s", missing) + } + return m, nil +} + +// Dim is the embedding size. +func (m *Model) Dim() int { return m.cfg.Hidden } + +// apply computes y = x·Wᵀ + b for n rows, the rows split across cores. +func (l linear) apply(x []float32, n int) []float32 { + y := make([]float32, n*l.out) + workers := min(runtime.GOMAXPROCS(0), max(l.out/32, 1)) + var wg sync.WaitGroup + chunk := (l.out + workers - 1) / workers + for w := range workers { + lo, hi := w*chunk, min((w+1)*chunk, l.out) + if lo >= hi { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + for o := lo; o < hi; o++ { + wr := l.w[o*l.in : (o+1)*l.in] + bias := l.b[o] + for r := range n { + y[r*l.out+o] = dot(x[r*l.in:(r+1)*l.in], wr) + bias + } + } + }() + } + wg.Wait() + return y +} + +func dot(a, b []float32) float32 { + var s0, s1, s2, s3 float32 + i := 0 + for ; i+4 <= len(a); i += 4 { + s0 += a[i] * b[i] + s1 += a[i+1] * b[i+1] + s2 += a[i+2] * b[i+2] + s3 += a[i+3] * b[i+3] + } + for ; i < len(a); i++ { + s0 += a[i] * b[i] + } + return s0 + s1 + s2 + s3 +} + +func (ln layerNorm) apply(x []float32, n, h int, eps float64) { + for r := range n { + row := x[r*h : (r+1)*h] + var mean float64 + for _, v := range row { + mean += float64(v) + } + mean /= float64(h) + var vr float64 + for _, v := range row { + d := float64(v) - mean + vr += d * d + } + inv := 1 / math.Sqrt(vr/float64(h)+eps) + for i, v := range row { + row[i] = float32((float64(v)-mean)*inv)*ln.w[i] + ln.b[i] + } + } +} + +func gelu(x float32) float32 { + return float32(0.5 * float64(x) * (1 + math.Erf(float64(x)/math.Sqrt2))) +} + +// Forward runs the encoder on one sequence and returns the mean of its +// token states (every token is real: one text, no padding), L2-normalised. +func (m *Model) Forward(ids []int32) []float32 { + h, n := m.cfg.Hidden, len(ids) + if n == 0 { + return make([]float32, h) + } + x := make([]float32, n*h) + for t, id := range ids { + if int(id) >= m.cfg.Vocab || t >= m.cfg.MaxPositions { + continue + } + row := x[t*h : (t+1)*h] + for i := range h { + row[i] = m.word[int(id)*h+i] + m.pos[t*h+i] + m.typ[i] + } + } + eps := m.cfg.Eps + m.embNorm.apply(x, n, h, eps) + heads := m.cfg.Heads + hd := h / heads + scale := float32(1 / math.Sqrt(float64(hd))) + for _, L := range m.layers { + q, k, v := L.q.apply(x, n), L.k.apply(x, n), L.v.apply(x, n) + ctx := make([]float32, n*h) + var wg sync.WaitGroup + for hh := range heads { + wg.Add(1) + go func() { + defer wg.Done() + scores := make([]float32, n) + for i := range n { + qi := q[i*h+hh*hd : i*h+hh*hd+hd] + maxS := float32(math.Inf(-1)) + for j := range n { + s := dot(qi, k[j*h+hh*hd:j*h+hh*hd+hd]) * scale + scores[j] = s + maxS = max(maxS, s) + } + var sum float32 + for j := range n { + scores[j] = float32(math.Exp(float64(scores[j] - maxS))) + sum += scores[j] + } + out := ctx[i*h+hh*hd : i*h+hh*hd+hd] + for j := range n { + p := scores[j] / sum + vj := v[j*h+hh*hd : j*h+hh*hd+hd] + for d := range hd { + out[d] += p * vj[d] + } + } + } + }() + } + wg.Wait() + a := L.o.apply(ctx, n) + for i := range a { + a[i] += x[i] + } + L.attnNorm.apply(a, n, h, eps) + inter := L.inter.apply(a, n) + for i := range inter { + inter[i] = gelu(inter[i]) + } + out := L.output.apply(inter, n) + for i := range out { + out[i] += a[i] + } + L.outNorm.apply(out, n, h, eps) + x = out + } + pooled := make([]float32, h) + for t := range n { + for i := range h { + pooled[i] += x[t*h+i] + } + } + var norm float64 + for i := range pooled { + pooled[i] /= float32(n) + norm += float64(pooled[i]) * float64(pooled[i]) + } + norm = math.Max(math.Sqrt(norm), 1e-12) + for i := range pooled { + pooled[i] = float32(float64(pooled[i]) / norm) + } + return pooled +} diff --git a/internal/embed/tokenizer.go b/internal/embed/tokenizer.go new file mode 100644 index 0000000..098e2db --- /dev/null +++ b/internal/embed/tokenizer.go @@ -0,0 +1,252 @@ +package embed + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +// Tokenizer is the BERT WordPiece tokenizer of a tokenizer.json, as the +// Hugging Face tokenizers crate runs it for all-MiniLM-L6-v2: +// BertNormalizer (clean text, space out CJK, strip accents, lowercase), +// BertPreTokenizer (whitespace and punctuation), WordPiece ("##", [UNK], +// 100 chars a word), then [CLS] … [SEP], truncated to MaxTokens. +type Tokenizer struct { + vocab map[string]int32 + unk int32 + cls, sep int32 + special []string // added tokens, matched on the raw text first + maxWord int + prefix string +} + +// MaxTokens is the desktop's truncation (atlas-embed spec max_tokens), +// [CLS] and [SEP] included; tokenizer.json's own 128 is overridden. +const MaxTokens = 512 + +// LoadTokenizer reads tokenizer.json. +func LoadTokenizer(path string) (*Tokenizer, error) { + b, err := os.ReadFile(path) //nolint:gosec // G304: the model's own file + if err != nil { + return nil, err + } + var tj struct { + Model struct { + Type string `json:"type"` + Vocab map[string]int32 `json:"vocab"` + Unk string `json:"unk_token"` + Prefix string `json:"continuing_subword_prefix"` + MaxWord int `json:"max_input_chars_per_word"` + } `json:"model"` + Added []struct { + Content string `json:"content"` + } `json:"added_tokens"` + } + if err := json.Unmarshal(b, &tj); err != nil { + return nil, fmt.Errorf("tokenizer.json: %w", err) + } + if tj.Model.Type != "WordPiece" || len(tj.Model.Vocab) == 0 { + return nil, fmt.Errorf("tokenizer.json: not a WordPiece tokenizer") + } + t := &Tokenizer{vocab: tj.Model.Vocab, prefix: tj.Model.Prefix, maxWord: tj.Model.MaxWord} + if t.prefix == "" { + t.prefix = "##" + } + if t.maxWord == 0 { + t.maxWord = 100 + } + var ok [3]bool + t.unk, ok[0] = t.vocab[orDefault(tj.Model.Unk, "[UNK]")] + t.cls, ok[1] = t.vocab["[CLS]"] + t.sep, ok[2] = t.vocab["[SEP]"] + if !ok[0] || !ok[1] || !ok[2] { + return nil, fmt.Errorf("tokenizer.json: missing [UNK], [CLS] or [SEP]") + } + for _, a := range tj.Added { + t.special = append(t.special, a.Content) + } + return t, nil +} + +func orDefault(s, d string) string { + if s == "" { + return d + } + return s +} + +// Encode returns the token ids of one text: [CLS] tokens [SEP]. +func (t *Tokenizer) Encode(text string) []int32 { + ids := []int32{t.cls} + for _, seg := range t.splitSpecial(text) { + if seg.special { + if id, ok := t.vocab[seg.text]; ok { + ids = append(ids, id) + } + continue + } + for _, w := range preTokenize(normalize(seg.text)) { + ids = append(ids, t.wordPiece(w)...) + } + } + if len(ids) > MaxTokens-1 { + ids = ids[:MaxTokens-1] + } + return append(ids, t.sep) +} + +type segment struct { + text string + special bool +} + +// splitSpecial cuts out the added tokens ([CLS], [SEP], …) written in the +// text itself; tokenizers matches them before normalizing. +func (t *Tokenizer) splitSpecial(text string) []segment { + var out []segment + for len(text) > 0 { + at, which := -1, "" + for _, s := range t.special { + if i := strings.Index(text, s); i >= 0 && (at < 0 || i < at || i == at && len(s) > len(which)) { + at, which = i, s + } + } + if at < 0 { + out = append(out, segment{text: text}) + break + } + if at > 0 { + out = append(out, segment{text: text[:at]}) + } + out = append(out, segment{text: which, special: true}) + text = text[at+len(which):] + } + return out +} + +// normalize is BertNormalizer with clean_text, handle_chinese_chars, +// strip_accents (following lowercase) and lowercase. +func normalize(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == 0 || r == 0xFFFD || isControl(r): + continue + case isWhitespace(r): + b.WriteByte(' ') + case isChinese(r): + b.WriteByte(' ') + b.WriteRune(r) + b.WriteByte(' ') + default: + b.WriteRune(r) + } + } + // Strip accents: NFD, then drop nonspacing marks. + var c strings.Builder + for _, r := range norm.NFD.String(b.String()) { + if !unicode.Is(unicode.Mn, r) { + c.WriteRune(r) + } + } + return strings.ToLower(c.String()) +} + +// isWhitespace is the normalizer's: tab, newline, return, and Unicode +// White_Space (checked after control characters were dropped). +func isWhitespace(r rune) bool { + switch r { + case '\t', '\n', '\r': + return true + } + return unicode.IsSpace(r) +} + +// isControl is tokenizers' is_control: every "other" category except tab, +// newline and carriage return (which are whitespace). +func isControl(r rune) bool { + switch r { + case '\t', '\n', '\r': + return false + } + if unicode.In(r, unicode.Cc, unicode.Cf, unicode.Co, unicode.Cs) { + return true + } + // Cn, unassigned: in no other category. + return !unicode.In(r, unicode.L, unicode.M, unicode.N, unicode.P, unicode.S, unicode.Z) +} + +func isChinese(r rune) bool { + return r >= 0x4E00 && r <= 0x9FFF || r >= 0x3400 && r <= 0x4DBF || r >= 0x20000 && r <= 0x2A6DF || + r >= 0x2A700 && r <= 0x2B73F || r >= 0x2B740 && r <= 0x2B81F || r >= 0x2B920 && r <= 0x2CEAF || + r >= 0xF900 && r <= 0xFAFF || r >= 0x2F800 && r <= 0x2FA1F +} + +// isPunct is BERT's punctuation: ASCII punctuation, or Unicode P*. +func isPunct(r rune) bool { + if r < 128 { + return r >= 33 && r <= 47 || r >= 58 && r <= 64 || r >= 91 && r <= 96 || r >= 123 && r <= 126 + } + return unicode.Is(unicode.P, r) +} + +// preTokenize splits on whitespace (dropped) and punctuation (kept, one +// character a word). +func preTokenize(s string) []string { + var out []string + var cur strings.Builder + flush := func() { + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + } + for _, r := range s { + switch { + case unicode.IsSpace(r): + flush() + case isPunct(r): + flush() + out = append(out, string(r)) + default: + cur.WriteRune(r) + } + } + flush() + return out +} + +// wordPiece is greedy longest-match-first; a word with a piece not in the +// vocabulary, or longer than maxWord characters, is [UNK]. +func (t *Tokenizer) wordPiece(word string) []int32 { + rs := []rune(word) + if len(rs) > t.maxWord { + return []int32{t.unk} + } + var ids []int32 + for start := 0; start < len(rs); { + end := len(rs) + found := int32(-1) + for end > start { + sub := string(rs[start:end]) + if start > 0 { + sub = t.prefix + sub + } + if id, ok := t.vocab[sub]; ok { + found = id + break + } + end-- + } + if found < 0 { + return []int32{t.unk} + } + ids = append(ids, found) + start = end + } + return ids +} diff --git a/internal/hooks/antigravity/antigravity.go b/internal/hooks/antigravity/antigravity.go index ee09ebb..fd04848 100644 --- a/internal/hooks/antigravity/antigravity.go +++ b/internal/hooks/antigravity/antigravity.go @@ -7,8 +7,11 @@ // names the model (modelName); the transcript is written step by step, the // USER_INPUT step before PreInvocation fires and the last PLANNER_RESPONSE // before Stop (older releases wrote it after Stop, which Settle still -// tolerates); and each PLANNER_RESPONSE step carries input_tokens, -// output_tokens and cache_read_tokens. +// tolerates); each PLANNER_RESPONSE step carries input_tokens, +// output_tokens and cache_read_tokens; and PreToolUse/PostToolUse carry the +// call (toolCall) and stepIdx, the index of the step its output will be, one +// after the PLANNER_RESPONSE that made it, which is not yet on disk when +// PostToolUse fires, so no hook sees the tool's output. package antigravity import ( @@ -18,7 +21,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -37,6 +42,7 @@ type Agent struct{} const ( VerbPreInvocation = "pre-invocation" VerbPreToolUse = "pre-tool-use" + VerbPostToolUse = "post-tool-use" VerbStop = "stop" ) @@ -55,7 +61,9 @@ func (Agent) Name() string { return "Antigravity" } func (Agent) Binary() string { return "agy" } // Verbs are the hook verbs the installed config invokes. -func (Agent) Verbs() []string { return []string{VerbPreInvocation, VerbPreToolUse, VerbStop} } +func (Agent) Verbs() []string { + return []string{VerbPreInvocation, VerbPreToolUse, VerbPostToolUse, VerbStop} +} // HooksPath is the workspace hooks file agy loads for a trusted workspace. func HooksPath(root string) string { return filepath.Join(root, ".agents", "hooks.json") } @@ -89,6 +97,9 @@ func render(disabled bool) entry { PreToolUse: []toolHandler{{Matcher: "*", Hooks: []hooks.CommandHook{ {Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbPreToolUse))}, }}}, + PostToolUse: []toolHandler{{Matcher: "*", Hooks: []hooks.CommandHook{ + {Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbPostToolUse))}, + }}}, PreInvocation: []plainHandler{{Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbPreInvocation))}}, Stop: []plainHandler{{Type: "command", Command: hooks.Wrap(hooks.Command(ID, VerbStop)), Timeout: stopTimeout}}, } @@ -181,9 +192,11 @@ func (Agent) Response(hooks.Kind, string) []byte { return nil } func (Agent) ResumeCommand(id string) string { return "agy --conversation " + id } // payload is the union of the fields read from agy's hook stdin. agy also -// sends artifactDirectoryPath, stepIdx, initialNumSteps, executionNum, -// terminationReason and error; only the ones below are used. modelName -// (e.g. "gemini-3.1-pro-low") is in every hook's payload on agy 1.3.3. +// sends artifactDirectoryPath, initialNumSteps, executionNum and +// terminationReason; only the ones below are used. modelName (e.g. +// "gemini-3.1-pro-low") is in every hook's payload on agy 1.3.3. stepIdx +// (Pre/PostToolUse) is the index of the step the tool's output will be +// written as; error (PostToolUse, Stop) is "" unless the tool or run failed. type payload struct { ConversationID string `json:"conversationId"` TranscriptPath string `json:"transcriptPath"` @@ -191,19 +204,23 @@ type payload struct { InvocationNum int `json:"invocationNum"` ModelName string `json:"modelName"` FullyIdle bool `json:"fullyIdle"` - ToolCall struct { - Name string `json:"name"` - Args json.RawMessage `json:"args"` - } `json:"toolCall"` + StepIdx *int `json:"stepIdx"` + Error string `json:"error"` + ToolCall toolCall `json:"toolCall"` } // Parse maps a hook verb and payload to an event: // -// - pre-invocation → TurnStart with no prompt (the dispatcher reads it -// from the transcript at turn end); agy fires one per model call, and a -// follow-up call within an open turn must not restart it; +// - pre-invocation → TurnStart. On the first model call of a turn the +// prompt is already in the transcript the payload names and no model +// step follows it, so it is read from there and opens the turn at once; +// a follow-up call within the turn (the transcript ends with model +// steps) carries no prompt, and the dispatcher keeps the turn open; // - pre-tool-use → ToolUse with the target file of a write tool, nothing // for any other tool; +// - post-tool-use → ToolUse with the completed call, keyed like the +// transcript reader keys it so the turn-end read does not record it +// twice; the payload has no output, only an error when the call failed; // - stop → TurnEnd when fullyIdle is true, nothing while background work // continues. func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { @@ -220,13 +237,26 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { switch verb { case VerbPreInvocation: ev.Kind = hooks.TurnStart + if p.TranscriptPath != "" { + ev.Prompt = pendingPrompt(p.TranscriptPath) + } case VerbPreToolUse: - files := writeTargets(p.ToolCall.Name, p.ToolCall.Args) + files := p.ToolCall.writeTargets() if len(files) == 0 { return nil, nil } ev.Kind = hooks.ToolUse ev.Files = files + case VerbPostToolUse: + if p.ToolCall.Name == "" || p.StepIdx == nil || *p.StepIdx < 1 { + return nil, nil + } + ev.Kind = hooks.ToolUse + ev.Tool = p.ToolCall.tool(callID(p.TranscriptPath, *p.StepIdx, p.ToolCall)) + if p.Error != "" { + msg := p.Error + ev.Tool.Status, ev.Tool.Result = "failed", &msg + } case VerbStop: if !p.FullyIdle { return nil, nil @@ -269,21 +299,63 @@ var toolKinds = map[string]string{ } // writeTargets is the file a write tool call names, or nothing. -func writeTargets(name string, args json.RawMessage) []string { - if !writeTools[name] { - return nil - } - var m map[string]json.RawMessage - if json.Unmarshal(args, &m) != nil { +func (tc toolCall) writeTargets() []string { + if !writeTools[tc.Name] { return nil } - target, _ := decodeString(m["TargetFile"]) + target, _ := decodeString(tc.Args["TargetFile"]) if target == "" { return nil } return []string{target} } +// callID is the id the transcript reader gives the call whose output step +// will be stepIdx: the PLANNER_RESPONSE step that made it (the last one +// before stepIdx, on disk before the tool runs) and the call's index in it. +// A planner step may hold several calls; their outputs follow it one step +// each in call order, so the index is the distance from the planner step. +// With no readable transcript or no step that fits, the call is taken as +// the only one of the step before. +func callID(path string, stepIdx int, tc toolCall) string { + fallback := strconv.Itoa(stepIdx-1) + "-0" + if path == "" { + return fallback + } + var planner *step + err := eachNonBlankLine(path, func(_ int, raw []byte) { + var s step + if json.Unmarshal(raw, &s) != nil || s.Type != "PLANNER_RESPONSE" || s.StepIndex >= stepIdx { + return + } + planner = &s + }) + if err != nil || planner == nil { + return fallback + } + i := stepIdx - planner.StepIndex - 1 + if i < 0 || i >= len(planner.ToolCalls) || planner.ToolCalls[i].Name != tc.Name { + return fallback + } + return strconv.Itoa(planner.StepIndex) + "-" + strconv.Itoa(i) +} + +// tool is the call as the recorder takes it, under id: the ACP kind the +// alias table cannot derive from agy's snake_case names, the arguments with +// their double encoding undone, and the target file of a write tool as its +// location. The hook payload and the transcript describe one call the same +// way, so the dispatcher's keys (id, name + arguments, written path) match. +func (tc toolCall) tool(id string) *record.Tool { + return &record.Tool{ + NativeID: id, + Name: tc.Name, + Kind: toolKinds[tc.Name], + Arguments: decodeArgs(tc.Args), + Locations: tc.writeTargets(), + Status: "completed", + } +} + // decodeString reads a string arg in either of agy's shapes: a plain JSON // string, or (its wire format) a JSON string holding a JSON-encoded string. // ok is false when the value is not a string at all. diff --git a/internal/hooks/antigravity/antigravity_test.go b/internal/hooks/antigravity/antigravity_test.go index e52c654..e23a157 100644 --- a/internal/hooks/antigravity/antigravity_test.go +++ b/internal/hooks/antigravity/antigravity_test.go @@ -1,12 +1,15 @@ package antigravity import ( + "bytes" "encoding/json" + "fmt" "os" "path/filepath" "strings" "testing" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -63,12 +66,14 @@ func TestInstallIntoEmpty(t *testing.T) { if len(file) != 1 { t.Fatalf("file has %d keys: %v", len(file), file) } + type group struct { + Matcher string `json:"matcher"` + Hooks []hooks.CommandHook `json:"hooks"` + } var e struct { - Enabled *bool `json:"enabled"` - PreToolUse []struct { - Matcher string `json:"matcher"` - Hooks []hooks.CommandHook `json:"hooks"` - } `json:"PreToolUse"` + Enabled *bool `json:"enabled"` + PreToolUse []group `json:"PreToolUse"` + PostToolUse []group `json:"PostToolUse"` PreInvocation []hooks.CommandHook `json:"PreInvocation"` Stop []hooks.CommandHook `json:"Stop"` } @@ -83,13 +88,18 @@ func TestInstallIntoEmpty(t *testing.T) { e.PreToolUse[0].Hooks[0].Type != "command" { t.Fatalf("PreToolUse = %+v", e.PreToolUse) } + if len(e.PostToolUse) != 1 || e.PostToolUse[0].Matcher != "*" || len(e.PostToolUse[0].Hooks) != 1 || + e.PostToolUse[0].Hooks[0].Command != hooks.Wrap("atlas hooks antigravity post-tool-use") || + e.PostToolUse[0].Hooks[0].Type != "command" { + t.Fatalf("PostToolUse = %+v", e.PostToolUse) + } if len(e.PreInvocation) != 1 || e.PreInvocation[0].Command != hooks.Wrap("atlas hooks antigravity pre-invocation") { t.Fatalf("PreInvocation = %+v", e.PreInvocation) } if len(e.Stop) != 1 || e.Stop[0].Command != hooks.Wrap("atlas hooks antigravity stop") || e.Stop[0].Timeout != 300 { t.Fatalf("Stop = %+v", e.Stop) } - for _, h := range []string{e.PreToolUse[0].Hooks[0].Command, e.PreInvocation[0].Command, e.Stop[0].Command} { + for _, h := range []string{e.PreToolUse[0].Hooks[0].Command, e.PostToolUse[0].Hooks[0].Command, e.PreInvocation[0].Command, e.Stop[0].Command} { if !hooks.Owned(h) { t.Fatalf("not owned: %q", h) } @@ -133,7 +143,7 @@ func TestInstallKeepsOtherEntriesAndDisabled(t *testing.T) { if len(e.Stop) != 1 || e.Stop[0].Command != hooks.Wrap("atlas hooks antigravity stop") { t.Fatalf("old Stop hook survived: %+v", e.Stop) } - if len(e.PreToolUse) != 1 || len(e.PreInvocation) != 1 { + if len(e.PreToolUse) != 1 || len(e.PostToolUse) != 1 || len(e.PreInvocation) != 1 { t.Fatalf("entry = %+v", e) } if a.State(root) != hooks.Current { @@ -217,7 +227,9 @@ func TestParse(t *testing.T) { {"write without target", VerbPreToolUse, `{"toolCall":{"name":"write_to_file","args":{"Overwrite":"true"}},` + common + `}`, 0, nil}, {"stop busy", VerbStop, `{"executionNum":1,"terminationReason":"model_stop","error":"","fullyIdle":false,` + common + `}`, 0, nil}, {"stop idle", VerbStop, `{"executionNum":1,"terminationReason":"model_stop","error":"","fullyIdle":true,` + common + `}`, hooks.TurnEnd, nil}, - {"unknown verb", "post-tool-use", `{` + common + `}`, 0, nil}, + {"post without a step", VerbPostToolUse, `{"toolCall":{"name":"run_command","args":{"CommandLine":"ls"}},"error":"",` + common + `}`, 0, nil}, + {"post without a call", VerbPostToolUse, `{"stepIdx":2,"error":"",` + common + `}`, 0, nil}, + {"unknown verb", "post-invocation", `{"invocationNum":0,` + common + `}`, 0, nil}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -255,3 +267,155 @@ func TestParse(t *testing.T) { t.Fatalf("empty payload = %+v, %v", ev, err) } } + +// postToolUse is the payload agy 1.3.3 sends after a tool ran (captured +// 2026-10-12): the call, stepIdx (the index of the step its output will be +// written as), an empty error, and no output. +const postToolUse = `{"artifactDirectoryPath":"/home/u/.gemini/antigravity-cli/brain/ec33ebf9","error":"","stepIdx":%d,"toolCall":{"args":%s,"name":"%s"},` + common + `}` + +func TestParsePostToolUse(t *testing.T) { + a := Agent{} + writeArgs := `{"Append":false,"CodeContent":"probe","Description":"Create probe.txt","Overwrite":false,"TargetFile":"/workspace/project/probe.txt","toolAction":"Creating probe.txt","toolSummary":"File creation"}` + ev, err := a.Parse(VerbPostToolUse, []byte(fmt.Sprintf(postToolUse, 2, writeArgs, "write_to_file"))) + if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.Tool == nil { + t.Fatalf("event = %+v, %v", ev, err) + } + if ev.SessionID != "ec33ebf9-0cba-4100-8142-c61503f6c587" || ev.Cwd != "/workspace/project" || ev.Model != "gemini-3.1-pro-low" || ev.Prompt != "" || len(ev.Files) != 0 { + t.Fatalf("event = %+v", ev) + } + w := ev.Tool + if w.NativeID != "1-0" || w.Name != "write_to_file" || w.Kind != "edit" || w.Status != "completed" || w.Result != nil || + len(w.Locations) != 1 || w.Locations[0] != "/workspace/project/probe.txt" { + t.Fatalf("tool = %+v", w) + } + var args map[string]any + if err := json.Unmarshal(w.Arguments, &args); err != nil { + t.Fatal(err) + } + if args["Append"] != false || args["CodeContent"] != "probe" || args["TargetFile"] != "/workspace/project/probe.txt" { + t.Fatalf("args = %s", w.Arguments) + } + if name := record.CanonicalToolName(w.Name, w.Title, w.Kind, w.Arguments); name != "Write" { + t.Fatalf("canonical name = %q, want Write", name) + } + + // The same call as the transcript records it: same id, arguments and + // location, so the dispatcher's keys match and it is not recorded twice. + path := filepath.Join(t.TempDir(), "transcript_full.jsonl") + line := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nmake it\n"}` + "\n" + + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"write_to_file","args":` + writeArgs + `}]}` + "\n" + + `{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","content":"Created"}` + "\n" + if err := os.WriteFile(path, []byte(line), 0o644); err != nil { + t.Fatal(err) + } + turn, _, err := reader{}.Read(path, 0) + if err != nil || len(turn.Items) != 1 || turn.Items[0].Tool == nil { + t.Fatalf("Read = %+v, %v", turn, err) + } + r := turn.Items[0].Tool + if r.NativeID != w.NativeID || r.Name != w.Name || r.Kind != w.Kind || !bytes.Equal(r.Arguments, w.Arguments) || + strings.Join(r.Locations, ",") != strings.Join(w.Locations, ",") { + t.Fatalf("hook tool %+v differs from transcript tool %+v", w, r) + } + + // Two calls in one planner step (seen live: write_to_file and + // run_command together in step 1, outputs at steps 2 and 3): the hook's + // stepIdx names the output step, and the id follows the reader's. + two := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nboth\n"}` + "\n" + + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"write_to_file","args":` + writeArgs + `},{"name":"run_command","args":{"CommandLine":"cat probe.txt","Cwd":"/workspace/project"}}]}` + "\n" + if err := os.WriteFile(path, []byte(two), 0o644); err != nil { + t.Fatal(err) + } + withPath := strings.Replace(postToolUse, `"transcriptPath":"/home/u/.gemini/antigravity-cli/brain/ec33ebf9/.system_generated/logs/transcript_full.jsonl"`, `"transcriptPath":%q`, 1) + for _, c := range []struct { + stepIdx int + name, args string + id string + }{ + {2, "write_to_file", writeArgs, "1-0"}, + {3, "run_command", `{"CommandLine":"cat probe.txt","Cwd":"/workspace/project"}`, "1-1"}, + {4, "run_command", `{"CommandLine":"ls"}`, "3-0"}, // no call at that index: the step before + {3, "view_file", `{"AbsolutePath":"/x"}`, "2-0"}, // a different call there: the step before + } { + ev, err := a.Parse(VerbPostToolUse, []byte(fmt.Sprintf(withPath, c.stepIdx, c.args, c.name, path))) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.NativeID != c.id { + t.Fatalf("stepIdx %d %s: event = %+v, %v; want id %s", c.stepIdx, c.name, ev, err, c.id) + } + } + turn, _, err = reader{}.Read(path, 0) + if err != nil || len(turn.Items) != 2 || turn.Items[0].Tool.NativeID != "1-0" || turn.Items[1].Tool.NativeID != "1-1" { + t.Fatalf("reader ids = %+v, %v", turn, err) + } + + // A shell call: execute kind, wire-shape (double encoded) args undone, + // and a failure reported in the payload's error. + raw := fmt.Sprintf(postToolUse, 4, `{"CommandLine":"\"cat probe.txt\"","Cwd":"/workspace/project","IsDaemon":false,"WaitMsBeforeAsync":5000}`, "run_command") + raw = strings.Replace(raw, `"error":""`, `"error":"exit status 1"`, 1) + ev, err = a.Parse(VerbPostToolUse, []byte(raw)) + if err != nil || ev == nil || ev.Tool == nil { + t.Fatalf("event = %+v, %v", ev, err) + } + run := ev.Tool + if run.NativeID != "3-0" || run.Kind != "execute" || run.Status != "failed" || run.Result == nil || *run.Result != "exit status 1" || run.Locations != nil { + t.Fatalf("tool = %+v", run) + } + if err := json.Unmarshal(run.Arguments, &args); err != nil { + t.Fatal(err) + } + if args["CommandLine"] != "cat probe.txt" || args["WaitMsBeforeAsync"] != float64(5000) { + t.Fatalf("args = %s", run.Arguments) + } + if name := record.CanonicalToolName(run.Name, run.Title, run.Kind, run.Arguments); name != "Bash" { + t.Fatalf("canonical name = %q, want Bash", name) + } +} + +// TestParsePreInvocationPrompt: the first invocation of a turn finds the +// prompt step already in the transcript and opens the turn with it; a +// follow-up invocation, whose transcript ends with the model's steps, does +// not; a transcript that is not there yet gives no prompt either. +func TestParsePreInvocationPrompt(t *testing.T) { + a := Agent{} + path := filepath.Join(t.TempDir(), "transcript_full.jsonl") + payload := func(n int) []byte { + return []byte(fmt.Sprintf(`{"invocationNum":%d,"initialNumSteps":1,"conversationId":"c1","modelName":"m","workspacePaths":["/w"],"transcriptPath":%q}`, n, path)) + } + ev, err := a.Parse(VerbPreInvocation, payload(0)) + if err != nil || ev == nil || ev.Kind != hooks.TurnStart || ev.Prompt != "" { + t.Fatalf("no transcript yet: %+v, %v", ev, err) + } + + prompt := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nCreate rt.txt, then run: cat rt.txt\n\n\nnow\n"}` + "\n" + if err := os.WriteFile(path, []byte(prompt), 0o644); err != nil { + t.Fatal(err) + } + ev, err = a.Parse(VerbPreInvocation, payload(0)) + if err != nil || ev == nil || ev.Kind != hooks.TurnStart || ev.Prompt != "Create rt.txt, then run: cat rt.txt" { + t.Fatalf("first invocation = %+v, %v", ev, err) + } + if ev.SessionID != "c1" || ev.Transcript != path || ev.Cwd != "/w" || ev.Model != "m" { + t.Fatalf("event = %+v", ev) + } + + model := `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"run_command","args":{"CommandLine":"cat rt.txt"}}]}` + "\n" + + `{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","content":"rt"}` + "\n" + if err := os.WriteFile(path, []byte(prompt+model), 0o644); err != nil { + t.Fatal(err) + } + ev, err = a.Parse(VerbPreInvocation, payload(1)) + if err != nil || ev == nil || ev.Kind != hooks.TurnStart || ev.Prompt != "" { + t.Fatalf("follow-up invocation = %+v, %v", ev, err) + } + + // The next turn's prompt, after a finished one (and agy's bookkeeping + // step after it), is the pending one. + next := prompt + model + `{"step_index":3,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nagain\n"}` + "\n" + + `{"step_index":4,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE"}` + "\n" + if err := os.WriteFile(path, []byte(next), 0o644); err != nil { + t.Fatal(err) + } + ev, err = a.Parse(VerbPreInvocation, payload(0)) + if err != nil || ev == nil || ev.Prompt != "again" { + t.Fatalf("second turn = %+v, %v", ev, err) + } +} diff --git a/internal/hooks/antigravity/state_main_test.go b/internal/hooks/antigravity/state_main_test.go new file mode 100644 index 0000000..f6879e2 --- /dev/null +++ b/internal/hooks/antigravity/state_main_test.go @@ -0,0 +1,15 @@ +package antigravity + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/antigravity/transcript.go b/internal/hooks/antigravity/transcript.go index b8a25d3..4d5cebc 100644 --- a/internal/hooks/antigravity/transcript.go +++ b/internal/hooks/antigravity/transcript.go @@ -13,7 +13,6 @@ import ( "strings" "time" - "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -119,6 +118,31 @@ func prompt(content string) string { return strings.TrimSpace(content) } +// pendingPrompt is the prompt of a USER_INPUT step that no model step +// follows: the turn agy is about to start answering when PreInvocation +// fires, since agy writes the prompt step before the hook. A transcript +// whose last steps are the model's (a follow-up invocation within a turn), +// or none at all, gives "". +func pendingPrompt(path string) string { + var pending string + err := eachNonBlankLine(path, func(_ int, raw []byte) { + var s step + if json.Unmarshal(raw, &s) != nil { + return + } + switch { + case s.Type == "USER_INPUT": + pending = prompt(s.Content) + case s.Source == "MODEL": + pending = "" + } + }) + if err != nil { + return "" + } + return pending +} + // Read parses the steps from a position into a turn. A step agy marks as // truncated is still read for what it holds. Usage is the sum of the steps' // token fields when any step has them. No model: the transcript names none @@ -154,20 +178,8 @@ func (reader) Read(path string, from int) (*hooks.Turn, int, error) { if tc.Name == "" { continue } - tool := &record.Tool{ - NativeID: id + "-" + strconv.Itoa(i), - Name: tc.Name, - Kind: toolKinds[tc.Name], - Arguments: decodeArgs(tc.Args), - Status: "completed", - } - if writeTools[tc.Name] { - if target, _ := decodeString(tc.Args["TargetFile"]); target != "" { - tool.Locations = []string{target} - } - } open = append(open, len(t.Items)) - t.Items = append(t.Items, hooks.Item{Tool: tool}) + t.Items = append(t.Items, hooks.Item{Tool: tc.tool(id + "-" + strconv.Itoa(i))}) } case "CONVERSATION_HISTORY", "SYSTEM_MESSAGE": // agy's own bookkeeping and injected notices. diff --git a/internal/hooks/antigravity/transcript_test.go b/internal/hooks/antigravity/transcript_test.go index 31d28dd..d37209c 100644 --- a/internal/hooks/antigravity/transcript_test.go +++ b/internal/hooks/antigravity/transcript_test.go @@ -249,6 +249,35 @@ func TestReadFromOffset(t *testing.T) { } } +func TestPendingPrompt(t *testing.T) { + if got := pendingPrompt(filepath.Join(t.TempDir(), "missing.jsonl")); got != "" { + t.Fatalf("missing transcript = %q", got) + } + // Both fixture turns are answered: nothing pending. + path := writeFixture(t) + if got := pendingPrompt(path); got != "" { + t.Fatalf("answered transcript = %q", got) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + _, _ = f.WriteString(`{"step_index":12,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nnext\n"}` + "\n" + + `{"step_index":13,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE"}` + "\n") + _ = f.Close() + if got := pendingPrompt(path); got != "next" { + t.Fatalf("pending = %q, want next", got) + } + // The live shape, cut after the prompt step as PreInvocation sees it. + live1 := filepath.Join(t.TempDir(), "transcript_full.jsonl") + if err := os.WriteFile(live1, []byte(strings.SplitAfter(live, "\n")[0]), 0o644); err != nil { + t.Fatal(err) + } + if got := pendingPrompt(live1); got != "Create a file named note.txt containing the word hello" { + t.Fatalf("live pending = %q", got) + } +} + func TestPromptUnwrap(t *testing.T) { if got := prompt("no wrapper here "); got != "no wrapper here" { t.Fatalf("prompt = %q", got) diff --git a/internal/hooks/claude/claude.go b/internal/hooks/claude/claude.go index 3a524fb..466dac1 100644 --- a/internal/hooks/claude/claude.go +++ b/internal/hooks/claude/claude.go @@ -3,11 +3,13 @@ package claude import ( + "bytes" "encoding/json" "os" "os/exec" "path/filepath" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -27,6 +29,7 @@ const ( VerbStopFailure = "stop-failure" VerbPreCompact = "pre-compact" VerbSessionEnd = "session-end" + VerbPostToolUse = "post-tool-use" ) // ID is the registry key. @@ -40,7 +43,7 @@ func (Agent) Binary() string { return "claude" } // Verbs are the hook verbs the installed config invokes. func (Agent) Verbs() []string { - return []string{VerbSessionStart, VerbPromptSubmit, VerbStop, VerbStopFailure, VerbPreCompact, VerbSessionEnd} + return []string{VerbSessionStart, VerbPromptSubmit, VerbPostToolUse, VerbStop, VerbStopFailure, VerbPreCompact, VerbSessionEnd} } // SettingsPath is the local (per-machine, uncommitted) Claude settings @@ -53,6 +56,7 @@ func (a Agent) shaped(root string) hooks.ClaudeShaped { return hooks.ClaudeShaped{Path: SettingsPath(root), Types: []hooks.ClaudeHook{ {Type: "SessionStart", Command: hooks.WrapWithMessage(hooks.Command(ID, VerbSessionStart), hooks.MissingMessage)}, {Type: "UserPromptSubmit", Command: hooks.Wrap(hooks.Command(ID, VerbPromptSubmit))}, + {Type: "PostToolUse", Command: hooks.Wrap(hooks.Command(ID, VerbPostToolUse))}, {Type: "Stop", Command: hooks.Wrap(hooks.Command(ID, VerbStop))}, {Type: "StopFailure", Command: hooks.Wrap(hooks.Command(ID, VerbStopFailure))}, {Type: "PreCompact", Command: hooks.Wrap(hooks.Command(ID, VerbPreCompact))}, @@ -95,12 +99,33 @@ func (Agent) ResumeCommand(id string) string { return "claude -r " + id } // payload is the common shape of Claude Code hook input. type payload struct { - SessionID string `json:"session_id"` - TranscriptPath string `json:"transcript_path"` - Cwd string `json:"cwd"` - Model string `json:"model"` - Prompt string `json:"prompt"` - Source string `json:"source"` + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Source string `json:"source"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response"` +} + +// toolOf is the completed call a PostToolUse payload describes. The +// response is a string or an object; either is kept as text. +func (p payload) toolOf() *record.Tool { + if p.ToolUseID == "" || p.ToolName == "" { + return nil + } + t := &record.Tool{NativeID: p.ToolUseID, Name: p.ToolName, Arguments: p.ToolInput, Status: "completed"} + if r := bytes.TrimSpace(p.ToolResponse); len(r) > 0 && string(r) != "null" { + var str string + if json.Unmarshal(r, &str) != nil { + str = string(r) + } + t.Result = &str + } + return t } // Parse maps a hook verb and payload to an event. @@ -123,6 +148,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { case VerbStopFailure: ev.Kind = hooks.TurnEnd ev.TurnEndFailed = true + case VerbPostToolUse: + ev.Kind = hooks.ToolUse + ev.Tool = p.toolOf() + if ev.Tool == nil { + return nil, nil + } case VerbPreCompact: ev.Kind = hooks.Compaction case VerbSessionEnd: diff --git a/internal/hooks/claude/claude_test.go b/internal/hooks/claude/claude_test.go index f57651e..bf5c8f1 100644 --- a/internal/hooks/claude/claude_test.go +++ b/internal/hooks/claude/claude_test.go @@ -28,6 +28,15 @@ func TestParse(t *testing.T) { t.Fatal("stop-failure must mark the turn failed") } } + post := `{"session_id":"abc","transcript_path":"/t/abc.jsonl","cwd":"/w","hook_event_name":"PostToolUse","tool_name":"Write","tool_use_id":"toolu_01","tool_input":{"file_path":"/w/x.txt","content":"hi"},"tool_response":{"filePath":"/w/x.txt","success":true}}` + ev, err = a.Parse(VerbPostToolUse, []byte(post)) + if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil || ev.Tool.NativeID != "toolu_01" || ev.Tool.Name != "Write" || + string(ev.Tool.Arguments) != `{"file_path":"/w/x.txt","content":"hi"}` || ev.Tool.Result == nil || !strings.Contains(*ev.Tool.Result, "success") { + t.Fatalf("post-tool-use → %+v %v", ev, err) + } + if ev, _ := a.Parse(VerbPostToolUse, []byte(`{"session_id":"abc"}`)); ev != nil { + t.Fatal("a tool payload without a call must be nil") + } if ev, err := a.Parse("unknown", []byte(payload)); ev != nil || err != nil { t.Fatal("unknown verb must be nil, nil") } @@ -67,7 +76,7 @@ func TestInstallWritesTheLocalSettings(t *testing.T) { if err := json.Unmarshal(b, &obj); err != nil { t.Fatal(err) } - for _, typ := range []string{"SessionStart", "UserPromptSubmit", "Stop", "StopFailure", "PreCompact", "SessionEnd"} { + for _, typ := range []string{"SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "StopFailure", "PreCompact", "SessionEnd"} { g := obj.Hooks[typ] if len(g) != 1 || len(g[0].Hooks) != 1 || g[0].Hooks[0].Type != "command" || !hooks.Owned(g[0].Hooks[0].Command) { t.Fatalf("%s: %+v", typ, g) diff --git a/internal/hooks/claude/state_main_test.go b/internal/hooks/claude/state_main_test.go new file mode 100644 index 0000000..c027a41 --- /dev/null +++ b/internal/hooks/claude/state_main_test.go @@ -0,0 +1,15 @@ +package claude + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/codex/codex.go b/internal/hooks/codex/codex.go index 4b3ad66..e291f5a 100644 --- a/internal/hooks/codex/codex.go +++ b/internal/hooks/codex/codex.go @@ -3,6 +3,7 @@ package codex import ( + "bytes" "encoding/json" "os" "os/exec" @@ -12,6 +13,7 @@ import ( "unicode/utf16" "unicode/utf8" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -111,6 +113,7 @@ type payload struct { Prompt string `json:"prompt"` ToolName string `json:"tool_name"` ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response"` ToolUseID string `json:"tool_use_id"` } @@ -134,14 +137,13 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { case VerbSessionEnd: ev.Kind = hooks.SessionEnd case VerbPostToolUse: - // Only the file-writing tool is a lifecycle event; shell and MCP - // calls are left to the transcript and the git status diff. - files := toolPatchFiles(p.ToolName, p.ToolInput) - if len(files) == 0 { + tool, files := p.toolOf() + if tool == nil && len(files) == 0 { return nil, nil } ev.Kind = hooks.ToolUse ev.ToolUseID = p.ToolUseID + ev.Tool = tool for _, f := range files { if !filepath.IsAbs(f) && p.Cwd != "" { f = filepath.Join(p.Cwd, f) @@ -154,6 +156,89 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { return ev, nil } +// toolOf is the completed call a PostToolUse payload describes, shaped so +// the dispatcher recognises the same call when it reads the rollout at turn +// end (toolKeys in dispatch.go), plus the files an apply_patch wrote for the +// git attribution. +// +// Codex 0.153 reports each tool the model used, in code mode the inner call +// of the exec script: apply_patch with the envelope under command, and Bash +// with the shell command under command. Its tool_use_id (exec-) is not +// the rollout's call_id (call_), so the id never matches and the other +// keys must: +// +// - apply_patch (also under its Write and Edit aliases, and a code-mode +// exec script that only calls tools.apply_patch once): the shape of +// transcript.go's patchTool, name "apply_patch", arguments {"patch": …} +// and the envelope's paths as locations, so both the arguments hash and +// every written path match; +// - Bash {"command": …}: the command text, which transcript.go reads out +// of a lone tools.exec_command({"cmd": …}) script (CodeModeCommand) or a +// shell call's command, matches as the shell key; +// - a code-mode exec script that does more: name "exec" with arguments +// {"input": script}, the rollout's custom_tool_call shape, so the +// arguments hash matches; the patches it holds still name their files; +// - anything else: the payload's name and arguments, re-encoded like the +// rollout's (canonJSON), so the arguments hash matches a function_call +// with the same arguments. +// +// The response is a string or an object; either is kept as text. +func (p payload) toolOf() (*record.Tool, []string) { + var tool *record.Tool + if p.ToolUseID != "" && p.ToolName != "" { + tool = &record.Tool{NativeID: p.ToolUseID, Name: p.ToolName, Arguments: canonJSON(p.ToolInput), Status: "completed"} + if r := bytes.TrimSpace(p.ToolResponse); len(r) > 0 && string(r) != "null" { + var str string + if json.Unmarshal(r, &str) != nil { + str = string(r) + } + tool.Result = &str + } + } + var files []string + switch { + case isApplyPatch(p.ToolName): + patch := patchText(p.ToolInput) + files = PatchFiles(patch) + if tool != nil && len(files) > 0 { + tool.Name, tool.Arguments, tool.Locations = patchArgs(patch, files) + } + case p.ToolName == "exec": + script := patchText(p.ToolInput) + patches, others := CodeModePatches(script) + files = PatchFiles(strings.Join(patches, "\n")) + if tool == nil || script == "" { + break + } + if len(patches) == 1 && !others { + tool.Name, tool.Arguments, tool.Locations = patchArgs(patches[0], files) + } else { + tool.Arguments = hooks.Raw(map[string]string{"input": script}) + } + } + return tool, files +} + +// patchArgs is the apply_patch shape shared with transcript.go's patchTool. +func patchArgs(patch string, files []string) (string, json.RawMessage, []string) { + return "apply_patch", hooks.Raw(map[string]string{"patch": patch}), files +} + +// canonJSON re-encodes a JSON value with Go's encoder (sorted object keys, +// no HTML escaping) so the same arguments hash the same whether they came +// from a hook payload or the rollout; anything that is not JSON is kept. +func canonJSON(raw json.RawMessage) json.RawMessage { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return nil + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return raw + } + return hooks.Raw(v) +} + // isApplyPatch recognises Codex's file tool: apply_patch, plus the Write and // Edit matcher aliases Codex registers for Claude-style hook configs. func isApplyPatch(name string) bool { @@ -164,19 +249,6 @@ func isApplyPatch(name string) bool { return false } -// toolPatchFiles lists the files a PostToolUse touched: an apply_patch call -// (also under the Write and Edit aliases), or a code-mode exec script that -// calls tools.apply_patch. Anything else is left to the transcript and the -// git status diff. -func toolPatchFiles(name string, input json.RawMessage) []string { - text := patchText(input) - if isApplyPatch(name) { - return PatchFiles(text) - } - patches, _ := CodeModePatches(text) - return PatchFiles(strings.Join(patches, "\n")) -} - // codeModeCall is a tool call in a code-mode script: tools.(. var codeModeCall = regexp.MustCompile(`\btools\.([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*`) @@ -202,6 +274,25 @@ func CodeModePatches(script string) (patches []string, others bool) { return patches, others } +// CodeModeCommand is the argument object of a code-mode script that calls +// tools.exec_command({…}) exactly once and no other tool, when that object +// is a JSON literal with a cmd string; the object is re-encoded with sorted +// keys. Anything else is not a lone command. +func CodeModeCommand(script string) (json.RawMessage, bool) { + calls := codeModeCall.FindAllStringSubmatchIndex(script, -1) + if len(calls) != 1 || script[calls[0][2]:calls[0][3]] != "exec_command" { + return nil, false + } + var obj map[string]any + if err := json.NewDecoder(strings.NewReader(script[calls[0][1]:])).Decode(&obj); err != nil { + return nil, false + } + if cmd, ok := obj["cmd"].(string); !ok || strings.TrimSpace(cmd) == "" { + return nil, false + } + return hooks.Raw(obj), true +} + // jsString decodes the JavaScript string literal that starts at s[i] with a // ", ' or ` quote. A template literal holding ${…} and an unterminated or // malformed literal fail. diff --git a/internal/hooks/codex/codex_test.go b/internal/hooks/codex/codex_test.go index 4c78e5e..d03f5bb 100644 --- a/internal/hooks/codex/codex_test.go +++ b/internal/hooks/codex/codex_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -170,29 +171,77 @@ func TestParse(t *testing.T) { t.Fatalf("%+v %v", ev, err) } + // apply_patch: the call is shaped like the rollout's (transcript.go + // patchTool) so the dispatcher's dedupe matches by arguments and by the + // written paths; Files are absolute for the git attribution. patch := "*** Begin Patch\n*** Add File: docs/new.md\n+hi\n*** Update File: main.go\n@@\n-a\n+b\n*** Delete File: /abs/old.txt\n*** End Patch" - ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_use_id":"call_1","tool_input":{"command":`+string(hooks.Raw(patch))+`}}`)) - if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.ToolUseID != "call_1" { + ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_use_id":"exec-1","tool_input":{"command":`+string(hooks.Raw(patch))+`},"tool_response":"Exit code: 0\nOutput:\nSuccess."}`)) + if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.ToolUseID != "exec-1" || ev.Tool == nil { t.Fatalf("%+v %v", ev, err) } if want := []string{"/repo/docs/new.md", "/repo/main.go", "/abs/old.txt"}; strings.Join(ev.Files, ",") != strings.Join(want, ",") { t.Fatalf("files %v", ev.Files) } + want := patchTool("exec-1", patch) + if ev.Tool.NativeID != want.NativeID || ev.Tool.Name != want.Name || string(ev.Tool.Arguments) != string(want.Arguments) || strings.Join(ev.Tool.Locations, ",") != strings.Join(want.Locations, ",") || ev.Tool.Status != "completed" { + t.Fatalf("got %+v\nwant %+v", ev.Tool, want) + } + if ev.Tool.Result == nil || *ev.Tool.Result != "Exit code: 0\nOutput:\nSuccess." { + t.Fatalf("result %v", ev.Tool.Result) + } + if record.CanonicalToolName(ev.Tool.Name, "", "", ev.Tool.Arguments) != "Edit" { + t.Fatal("apply_patch should canonicalise to Edit") + } - // The Write and Edit aliases are the same tool. + // The Write and Edit aliases are the same tool; without a tool_use_id + // there is no call to deliver, but the files still count. ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"Edit","tool_input":{"patch":"*** Begin Patch\n*** Update File: a.go\n*** End Patch"}}`)) - if err != nil || ev == nil || ev.Kind != hooks.ToolUse || len(ev.Files) != 1 || ev.Files[0] != "/repo/a.go" { + if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.Tool != nil || len(ev.Files) != 1 || ev.Files[0] != "/repo/a.go" { t.Fatalf("%+v %v", ev, err) } - // A shell call is not a lifecycle event. - ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"shell","tool_input":{"command":["ls"]}}`)) - if err != nil || ev != nil { + // A shell call (Codex reports a code-mode exec_command as Bash with the + // command text): the call is delivered with its arguments re-encoded + // and an object response kept as text; no files. + ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"Bash","tool_use_id":"exec-2","tool_input":{"workdir":"/repo","command":"cat rt.txt"},"tool_response":{"output":"realtime\n","exit_code":0}}`)) + if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.Tool == nil || len(ev.Files) != 0 { + t.Fatalf("%+v %v", ev, err) + } + if ev.Tool.NativeID != "exec-2" || ev.Tool.Name != "Bash" || string(ev.Tool.Arguments) != `{"command":"cat rt.txt","workdir":"/repo"}` || ev.Tool.Status != "completed" { + t.Fatalf("%+v", ev.Tool) + } + if ev.Tool.Result == nil || *ev.Tool.Result != `{"output":"realtime\n","exit_code":0}` { + t.Fatalf("result %v", ev.Tool.Result) + } + // The same command in the rollout's code-mode script yields the same + // command text, which is the dispatcher's shell key. + args, ok := CodeModeCommand(`const r = await tools.exec_command({"cmd":"cat rt.txt","workdir":"/repo","yield_time_ms":10000});` + "\ntext(r.output);\n") + if !ok { + t.Fatal("expected a lone exec_command") + } + var hookArgs, rolloutArgs struct { + Command string `json:"command"` + Cmd string `json:"cmd"` + } + if json.Unmarshal(ev.Tool.Arguments, &hookArgs) != nil || json.Unmarshal(args, &rolloutArgs) != nil || hookArgs.Command+hookArgs.Cmd != rolloutArgs.Command+rolloutArgs.Cmd { + t.Fatalf("hook %s, rollout %s", ev.Tool.Arguments, args) + } + + // An MCP or other tool: delivered as named, a null response is no result. + ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"mcp__atlas_memory__memory_search","tool_use_id":"exec-3","tool_input":{"query":"hooks"},"tool_response":null}`)) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "mcp__atlas_memory__memory_search" || string(ev.Tool.Arguments) != `{"query":"hooks"}` || ev.Tool.Result != nil || len(ev.Files) != 0 { t.Fatalf("%+v %v", ev, err) } - // Nor is an apply_patch whose envelope names no file. - ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_input":{"command":"garbage"}}`)) + // An apply_patch whose envelope names no file is still a call, just not + // an edit. + ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"apply_patch","tool_use_id":"exec-4","tool_input":{"command":"garbage"}}`)) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "apply_patch" || string(ev.Tool.Arguments) != `{"command":"garbage"}` || len(ev.Tool.Locations) != 0 || len(ev.Files) != 0 { + t.Fatalf("%+v %v", ev, err) + } + + // Nothing to deliver: no id and no files. + ev, err = a.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"shell","tool_input":{"command":["ls"]}}`)) if err != nil || ev != nil { t.Fatalf("%+v %v", ev, err) } @@ -302,16 +351,62 @@ func TestCodeModePatches(t *testing.T) { } func TestParseCodeModeExec(t *testing.T) { - // A PostToolUse for the exec tool whose script edits a file still names - // the file; a script that only runs a shell command does not. + // A PostToolUse for the exec tool itself, carrying the code-mode script. common := `"session_id":"s1","transcript_path":"/t/rollout.jsonl","cwd":"/repo","model":"gpt-5-codex"` - script := `await tools.apply_patch("*** Begin Patch\n*** Add File: note.txt\n+hi\n*** End Patch");` + patch := "*** Begin Patch\n*** Add File: note.txt\n+hi\n*** End Patch" + script := `await tools.apply_patch(` + string(hooks.Raw(patch)) + `);` + + // A script with one patch and nothing else is that apply_patch call, + // shaped like the rollout's. ev, err := Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_use_id":"exec-1","tool_input":{"input":`+string(hooks.Raw(script))+`}}`)) if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.ToolUseID != "exec-1" || len(ev.Files) != 1 || ev.Files[0] != "/repo/note.txt" { t.Fatalf("%+v %v", ev, err) } + want := patchTool("exec-1", patch) + if ev.Tool == nil || ev.Tool.NativeID != "exec-1" || ev.Tool.Name != "apply_patch" || string(ev.Tool.Arguments) != string(want.Arguments) || strings.Join(ev.Tool.Locations, ",") != "note.txt" { + t.Fatalf("got %+v\nwant %+v", ev.Tool, want) + } + + // A script that also runs a command stays the exec call, in the + // rollout's {"input": script} shape, and still names the patched files. + mixed := `await tools.shell({command: 'ls'}); ` + script + ` await tools.apply_patch("*** Begin Patch\n*** Delete File: old.txt\n*** End Patch");` + ev, err = Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_use_id":"exec-2","tool_input":{"input":`+string(hooks.Raw(mixed))+`}}`)) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "exec" || string(ev.Tool.Arguments) != string(hooks.Raw(map[string]string{"input": mixed})) { + t.Fatalf("%+v %v", ev, err) + } + if strings.Join(ev.Files, ",") != "/repo/note.txt,/repo/old.txt" { + t.Fatalf("files %v", ev.Files) + } + + // A script that only runs a shell command is the exec call with no files. + ev, err = Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_use_id":"exec-3","tool_input":{"input":"await tools.shell({command: 'ls'})"}}`)) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "exec" || len(ev.Files) != 0 { + t.Fatalf("%+v %v", ev, err) + } + // Without a tool_use_id a shell-only script is nothing. ev, err = Agent{}.Parse(VerbPostToolUse, []byte(`{`+common+`,"tool_name":"exec","tool_input":{"input":"await tools.shell({command: 'ls'})"}}`)) if err != nil || ev != nil { t.Fatalf("%+v %v", ev, err) } } + +func TestCodeModeCommand(t *testing.T) { + args, ok := CodeModeCommand(`const r = await tools.exec_command({"cmd":"cat rt.txt","workdir":"/repo","yield_time_ms":10000,"max_output_tokens":2000});` + "\ntext(r.output);\n") + if !ok || string(args) != `{"cmd":"cat rt.txt","max_output_tokens":2000,"workdir":"/repo","yield_time_ms":10000}` { + t.Fatalf("%s %v", args, ok) + } + for _, bad := range []string{ + `tools.exec_command({cmd: 'ls'})`, // a JS literal, not JSON + `tools.exec_command({"cmd":""})`, + `tools.exec_command({"workdir":"/repo"})`, + `tools.exec_command(args)`, + `tools.exec_command({"cmd":"ls"}); tools.exec_command({"cmd":"pwd"})`, + `tools.shell({"command":"ls"})`, + `tools.exec_command({"cmd":"ls"}); tools.apply_patch("*** Begin Patch\n*** Add File: a\n*** End Patch")`, + "", + } { + if args, ok := CodeModeCommand(bad); ok { + t.Fatalf("%s: %s", bad, args) + } + } +} diff --git a/internal/hooks/codex/state_main_test.go b/internal/hooks/codex/state_main_test.go new file mode 100644 index 0000000..e78e875 --- /dev/null +++ b/internal/hooks/codex/state_main_test.go @@ -0,0 +1,15 @@ +package codex + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/codex/transcript.go b/internal/hooks/codex/transcript.go index 9124de3..c379da5 100644 --- a/internal/hooks/codex/transcript.go +++ b/internal/hooks/codex/transcript.go @@ -180,11 +180,20 @@ func readItem(t *hooks.Turn, tools map[string]int, it item, idx int) { return } // Code mode (Codex 0.153): the model edits files from a JavaScript - // script run by the exec tool, as tools.apply_patch("…"). Each patch + // script run by the exec tool, as tools.apply_patch("…"), and runs + // shell commands as tools.exec_command({"cmd": …}). Each patch // becomes its own apply_patch call so the edit and its files are - // attributed; the script stays only when it also called other - // tools, and then it keeps the call id and the output. + // attributed; a script that only runs one command becomes that + // exec_command call, whose cmd is what the PostToolUse hook reported + // as the command (codex.go toolOf); the script itself stays only + // when it did more, and then it keeps the call id and the output. patches, others := CodeModePatches(it.Input) + if len(patches) == 0 { + if args, ok := CodeModeCommand(it.Input); ok { + addTool(t, tools, &record.Tool{NativeID: it.CallID, Name: "exec_command", Arguments: args, Status: "completed"}) + return + } + } if len(patches) == 0 || others { addTool(t, tools, &record.Tool{NativeID: it.CallID, Name: it.Name, Status: "completed", Arguments: hooks.Raw(map[string]string{"input": it.Input})}) } diff --git a/internal/hooks/codex/transcript_test.go b/internal/hooks/codex/transcript_test.go index cf4d57d..730f6f5 100644 --- a/internal/hooks/codex/transcript_test.go +++ b/internal/hooks/codex/transcript_test.go @@ -302,6 +302,8 @@ func TestReadCodeMode(t *testing.T) { `{"timestamp":"2026-10-12T02:39:28.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_Sn9","output":[{"type":"input_text","text":"Script completed\nWall time 0.6 seconds\nOutput:\n"},{"type":"input_text","text":"{}"}]}}`, `{"timestamp":"2026-10-12T02:39:29.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_mix","name":"exec","input":"await tools.shell({command: 'go test'}); await tools.apply_patch(\"*** Begin Patch\\n*** Update File: a.go\\n*** End Patch\");"}}`, `{"timestamp":"2026-10-12T02:39:30.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_mix","output":"ok"}}`, + `{"timestamp":"2026-10-12T02:39:31.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_cmd","name":"exec","input":"const r = await tools.exec_command({\"cmd\":\"cat rt.txt\",\"workdir\":\"/private/tmp/hooks-codex\",\"yield_time_ms\":10000,\"max_output_tokens\":2000});\ntext(r.output);\n"}}`, + `{"timestamp":"2026-10-12T02:39:32.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_cmd","output":[{"type":"input_text","text":"Script completed\nWall time 0.2 seconds\nOutput:\n"},{"type":"input_text","text":"realtime\n"}]}}`, } p := filepath.Join(t.TempDir(), "rollout.jsonl") if err := os.WriteFile(p, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { @@ -311,7 +313,7 @@ func TestReadCodeMode(t *testing.T) { if err != nil { t.Fatal(err) } - if len(turn.Items) != 3 { + if len(turn.Items) != 4 { t.Fatalf("items %d: %+v", len(turn.Items), turn.Items) } // A script that only edits is the edit, with the script's output. @@ -333,4 +335,13 @@ func TestReadCodeMode(t *testing.T) { if ed == nil || ed.Name != "apply_patch" || ed.NativeID != "call_mix#patch1" || len(ed.Locations) != 1 || ed.Locations[0] != "a.go" || ed.Result != nil { t.Fatalf("patch %+v", ed) } + // A script that only runs one command is that command, with cmd at the + // top level (the hook reports the same text as command) and the output. + cmd := turn.Items[3].Tool + if cmd == nil || cmd.Name != "exec_command" || cmd.NativeID != "call_cmd" || !strings.Contains(string(cmd.Arguments), `"cmd":"cat rt.txt"`) || cmd.Result == nil || !strings.HasSuffix(*cmd.Result, "realtime\n") { + t.Fatalf("command %+v", cmd) + } + if record.CanonicalToolName(cmd.Name, "", "", cmd.Arguments) != "Bash" { + t.Fatal("exec_command should canonicalise to Bash") + } } diff --git a/internal/hooks/copilot/copilot.go b/internal/hooks/copilot/copilot.go index 7877994..4ddf077 100644 --- a/internal/hooks/copilot/copilot.go +++ b/internal/hooks/copilot/copilot.go @@ -36,6 +36,7 @@ type Agent struct{} const ( VerbSessionStart = "session-start" VerbPromptSubmitted = "user-prompt-submitted" + VerbPostToolUse = "post-tool-use" VerbAgentStop = "agent-stop" VerbSessionEnd = "session-end" ) @@ -44,6 +45,7 @@ const ( const ( eventSessionStart = "sessionStart" eventUserPromptSubmitted = "userPromptSubmitted" + eventPostToolUse = "postToolUse" eventAgentStop = "agentStop" eventSessionEnd = "sessionEnd" ) @@ -59,7 +61,7 @@ func (Agent) Binary() string { return "copilot" } // Verbs are the hook verbs the installed config invokes. func (Agent) Verbs() []string { - return []string{VerbSessionStart, VerbPromptSubmitted, VerbAgentStop, VerbSessionEnd} + return []string{VerbSessionStart, VerbPromptSubmitted, VerbPostToolUse, VerbAgentStop, VerbSessionEnd} } // HooksPath is the hook file Atlas owns. Copilot auto-discovers every JSON @@ -90,6 +92,7 @@ func wanted() hookFile { return hookFile{Version: 1, Hooks: map[string][]hookEntry{ eventSessionStart: entry(VerbSessionStart), eventUserPromptSubmitted: entry(VerbPromptSubmitted), + eventPostToolUse: entry(VerbPostToolUse), eventAgentStop: entry(VerbAgentStop), eventSessionEnd: entry(VerbSessionEnd), }} @@ -191,7 +194,10 @@ func TranscriptPath(sessionID string) string { // Parse maps a hook verb and payload to an event. Copilot's keys are // camelCase (sessionId, transcriptPath, cwd, prompt); the snake_case forms -// VS Code's hook host sends are accepted too. +// VS Code's hook host sends are accepted too. A postToolUse payload becomes +// a ToolUse event carrying the completed call (tool.go), which the dispatcher +// records into the open turn as it fires; a payload naming no tool is +// nothing to do. func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { m, err := hooks.Decode(raw) if err != nil { @@ -208,6 +214,11 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { case VerbPromptSubmitted: ev.Kind = hooks.TurnStart ev.Prompt = hooks.Str(m, "prompt") + case VerbPostToolUse: + ev.Kind = hooks.ToolUse + if ev.Tool = toolOf(raw, ev.Cwd); ev.Tool == nil { + return nil, nil + } case VerbAgentStop: ev.Kind = hooks.TurnEnd case VerbSessionEnd: diff --git a/internal/hooks/copilot/copilot_test.go b/internal/hooks/copilot/copilot_test.go index 02a39e7..2b0a317 100644 --- a/internal/hooks/copilot/copilot_test.go +++ b/internal/hooks/copilot/copilot_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -24,7 +25,7 @@ func TestRegisteredAndNamed(t *testing.T) { if a.ResumeCommand("abc") != "copilot --resume abc" { t.Fatal(a.ResumeCommand("abc")) } - if len(a.Verbs()) != 4 { + if len(a.Verbs()) != 5 { t.Fatal(a.Verbs()) } n, ok := a.(hooks.Noter) @@ -58,12 +59,12 @@ func TestInstallStateUninstall(t *testing.T) { if err := json.Unmarshal(b, &file); err != nil { t.Fatal(err) } - if file.Version != 1 || len(file.Hooks) != 4 { + if file.Version != 1 || len(file.Hooks) != 5 { t.Fatalf("%s", b) } for ev, verb := range map[string]string{ "sessionStart": "session-start", "userPromptSubmitted": "user-prompt-submitted", - "agentStop": "agent-stop", "sessionEnd": "session-end", + "postToolUse": "post-tool-use", "agentStop": "agent-stop", "sessionEnd": "session-end", } { list := file.Hooks[ev] if len(list) != 1 || list[0].Type != "command" { @@ -211,3 +212,106 @@ func TestTranscriptPathDefaultsToTheHomeDirectory(t *testing.T) { t.Fatalf("%q != %q", got, want) } } + +// The postToolUse payloads below are what Copilot CLI 1.0.95 sent to an echo +// hook: no tool call id; toolArgs an object for bash and MCP tools, the bare +// patch string for apply_patch; toolResult {resultType, textResultForLlm}. +func TestParsePostToolUse(t *testing.T) { + t.Setenv("COPILOT_HOME", "/copilot-home") + a := Agent{} + const id = "c90a3853-49f2-4828-9293-c24163db8c36" + derived := filepath.Join("/copilot-home", "session-state", id, "events.jsonl") + + shell := `{"sessionId":"` + id + `","timestamp":1791759613789,"cwd":"/private/tmp/hooks-copilot","toolName":"bash","toolArgs":{"command":"cat echo2.txt","description":"Read echo2.txt"},"toolResult":{"resultType":"success","textResultForLlm":"hello\n"}}` + ev, err := a.Parse(VerbPostToolUse, []byte(shell)) + if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.SessionID != id || ev.Cwd != "/private/tmp/hooks-copilot" || ev.Transcript != derived { + t.Fatalf("%+v %v", ev, err) + } + tool := ev.Tool + if tool == nil || tool.Name != "bash" || tool.Status != "completed" || len(tool.Locations) != 0 || + string(tool.Arguments) != `{"command":"cat echo2.txt","description":"Read echo2.txt"}` || + tool.Result == nil || *tool.Result != "hello\n" { + t.Fatalf("%+v", tool) + } + if !strings.HasPrefix(tool.NativeID, "hook-") || len(tool.NativeID) != len("hook-")+24 { + t.Fatalf("the payload has no call id; a derived one is expected: %q", tool.NativeID) + } + if record.CanonicalToolName(tool.Name, tool.Title, tool.Kind, tool.Arguments) != "Bash" { + t.Fatal("bash must record as Bash") + } + // The same payload derives the same id; another timestamp another one. + again, _ := a.Parse(VerbPostToolUse, []byte(shell)) + if again.Tool.NativeID != tool.NativeID { + t.Fatalf("%q != %q", again.Tool.NativeID, tool.NativeID) + } + later, _ := a.Parse(VerbPostToolUse, []byte(strings.Replace(shell, "1791759613789", "1791759613790", 1))) + if later.Tool.NativeID == tool.NativeID { + t.Fatal("two calls at different times must not share an id") + } + + patch := `{"sessionId":"` + id + `","timestamp":1791759612107,"cwd":"/private/tmp/hooks-copilot","toolName":"apply_patch","toolArgs":"*** Begin Patch\n*** Add File: echo2.txt\n+hello\n*** End Patch\n","toolResult":{"resultType":"success","textResultForLlm":"Added 1 file(s): /private/tmp/hooks-copilot/echo2.txt"}}` + ev, err = a.Parse(VerbPostToolUse, []byte(patch)) + if err != nil || ev == nil || ev.Tool == nil { + t.Fatalf("%+v %v", ev, err) + } + tool = ev.Tool + if tool.Name != "apply_patch" || tool.Status != "completed" || + string(tool.Arguments) != `{"patch":"*** Begin Patch\n*** Add File: echo2.txt\n+hello\n*** End Patch\n"}` || + tool.Result == nil || *tool.Result != "Added 1 file(s): /private/tmp/hooks-copilot/echo2.txt" { + t.Fatalf("%+v", tool) + } + if len(tool.Locations) != 1 || tool.Locations[0] != "/private/tmp/hooks-copilot/echo2.txt" { + t.Fatalf("the patch's file, absolute like the transcript's fileEdits: %v", tool.Locations) + } + if record.CanonicalToolName(tool.Name, tool.Title, tool.Kind, tool.Arguments) != "Edit" { + t.Fatal("apply_patch must record as Edit") + } + if paths := record.WritePaths(tool.Arguments, tool.Locations); len(paths) != 1 || paths[0] != "/private/tmp/hooks-copilot/echo2.txt" { + t.Fatalf("the recorder must sample the patched file: %v", paths) + } + if record.EditPatch(tool.Arguments, tool.Locations[0]) != "*** Begin Patch\n*** Add File: echo2.txt\n+hello\n*** End Patch\n" { + t.Fatal("the patch must be kept as the edit's patch") + } + + mcp := `{"sessionId":"` + id + `","timestamp":1791759578328,"cwd":"/private/tmp/hooks-copilot","toolName":"atlas_memory-memory_briefing","toolArgs":{},"toolResult":{"resultType":"success","textResultForLlm":"{\"filesChanged\":[]}"}}` + ev, err = a.Parse(VerbPostToolUse, []byte(mcp)) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Name != "atlas_memory-memory_briefing" || string(ev.Tool.Arguments) != `{}` || + ev.Tool.Result == nil || *ev.Tool.Result != `{"filesChanged":[]}` || len(ev.Tool.Locations) != 0 { + t.Fatalf("%+v %v", ev, err) + } + + // A result that is not a success fails the call; a string or absent + // result is kept as is; a string argument that is no patch is wrapped. + ev, _ = a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":1,"toolName":"bash","toolArgs":{"command":"rm -rf /"},"toolResult":{"resultType":"denied","textResultForLlm":"The user denied this tool call."}}`)) + if ev.Tool.Status != "failed" || *ev.Tool.Result != "The user denied this tool call." { + t.Fatalf("%+v", ev.Tool) + } + ev, _ = a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":2,"toolName":"view","toolArgs":"x.txt","toolResult":"hello"}`)) + if ev.Tool.Status != "completed" || *ev.Tool.Result != "hello" || string(ev.Tool.Arguments) != `{"input":"x.txt"}` || ev.Tool.Locations != nil { + t.Fatalf("%+v", ev.Tool) + } + ev, _ = a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":3,"toolName":"think"}`)) + if ev.Tool.Result != nil || ev.Tool.Arguments != nil || ev.Tool.Status != "completed" { + t.Fatalf("%+v", ev.Tool) + } + + // No tool named: nothing to do. + if ev, err := a.Parse(VerbPostToolUse, []byte(`{"sessionId":"`+id+`","timestamp":4}`)); err != nil || ev != nil { + t.Fatalf("%+v %v", ev, err) + } +} + +func TestPatchPaths(t *testing.T) { + patch, _ := json.Marshal("*** Begin Patch\n*** Update File: src/a.go\n*** Move to: src/b.go\n@@\n-x\n+y\n*** Delete File: old.txt\n*** Add File: /abs/new.txt\n+n\n*** Update File: src/a.go\n*** End Patch\n") + got := patchPaths(patch, "/repo") + want := []string{"/repo/src/a.go", "/repo/src/b.go", "/repo/old.txt", "/abs/new.txt"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("%v", got) + } + if got := patchPaths(patch, ""); got[0] != "src/a.go" { + t.Fatalf("no cwd keeps the path relative: %v", got) + } + if patchPaths([]byte(`{"path":"x"}`), "/repo") != nil || patchPaths([]byte(`"not a patch"`), "/repo") != nil { + t.Fatal("only a patch string names files") + } +} diff --git a/internal/hooks/copilot/state_main_test.go b/internal/hooks/copilot/state_main_test.go new file mode 100644 index 0000000..4c35518 --- /dev/null +++ b/internal/hooks/copilot/state_main_test.go @@ -0,0 +1,15 @@ +package copilot + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/copilot/tool.go b/internal/hooks/copilot/tool.go new file mode 100644 index 0000000..456de18 --- /dev/null +++ b/internal/hooks/copilot/tool.go @@ -0,0 +1,147 @@ +package copilot + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + + "github.com/tryatlas/atlas-cli/internal/capture/record" + "github.com/tryatlas/atlas-cli/internal/capture/store" +) + +// toolPayload is Copilot's postToolUse input (1.0.95): {sessionId, +// timestamp, cwd, toolName, toolArgs, toolResult}. toolArgs is the tool's +// arguments object ({"command":…,"description":…} for bash, {} for an MCP +// tool), or apply_patch's bare patch string. toolResult is +// {"resultType":"success"|…,"textResultForLlm":…}; a shell command that +// exited non-zero is still a "success" (the tool ran), as in the transcript. +// The payload carries no tool call id: the transcript's toolCallId is not in +// it. +type toolPayload struct { + SessionID string `json:"sessionId"` + Timestamp json.RawMessage `json:"timestamp"` + ToolName string `json:"toolName"` + ToolArgs json.RawMessage `json:"toolArgs"` + ToolResult json.RawMessage `json:"toolResult"` +} + +type toolResult struct { + ResultType string `json:"resultType"` + Text string `json:"textResultForLlm"` +} + +// toolOf is the completed call a postToolUse payload describes, or nil when +// it names no tool. cwd resolves the relative paths of a patch. +func toolOf(raw []byte, cwd string) *record.Tool { + var p toolPayload + if json.Unmarshal(raw, &p) != nil || p.ToolName == "" { + return nil + } + args := toolArguments(p.ToolArgs) + t := &record.Tool{ + NativeID: hookCallID(p.SessionID, string(bytes.TrimSpace(p.Timestamp)), p.ToolName, args), + Name: p.ToolName, + Arguments: args, + Locations: patchPaths(p.ToolArgs, cwd), + Status: "completed", + } + res := bytes.TrimSpace(p.ToolResult) + if !isSet(res) { + return t + } + var r toolResult + if res[0] == '{' && json.Unmarshal(res, &r) == nil { + if r.ResultType != "" && r.ResultType != "success" { + t.Status = "failed" + } + text := r.Text + if text == "" { + text = string(res) + } + t.Result = &text + } else if text, ok := resultText(res); ok { + t.Result = &text + } + return t +} + +// hookCallID stands in for the call id the payload lacks: a digest of the +// session, the payload's timestamp, the tool and its arguments, so a +// re-delivered payload upserts the same row and two calls of one tool in the +// same millisecond stay apart. It never equals the transcript's toolCallId, +// so the dispatcher's dedupe at turn end goes by the arguments hash, the +// shell command text and the written paths, all of which this package shapes +// the same way on both sides. +func hookCallID(session, timestamp, name string, args json.RawMessage) string { + var compact bytes.Buffer + _ = json.Compact(&compact, args) + sum := store.SHA256Hex([]byte(session + "\x00" + timestamp + "\x00" + name + "\x00" + compact.String())) + return "hook-" + sum[:24] +} + +// toolArguments normalises a call's arguments to the JSON object the +// recorder and the dispatcher's dedupe keys read. An object stays as it is. +// apply_patch's bare patch string becomes {"patch": …}, which +// record.EditPatch keeps as the edit's patch; any other string becomes +// {"input": …}. Nothing stays nothing. The transcript reader applies the +// same shape, so a hook-delivered call and its transcript item hash alike. +func toolArguments(raw json.RawMessage) json.RawMessage { + raw = bytes.TrimSpace(raw) + if !isSet(raw) { + return nil + } + if raw[0] != '"' { + return raw + } + var s string + if json.Unmarshal(raw, &s) != nil { + return raw + } + key := "input" + if isPatch(s) { + key = "patch" + } + b, _ := json.Marshal(map[string]string{key: s}) + return b +} + +// isPatch recognises the apply_patch format. +func isPatch(s string) bool { return strings.HasPrefix(strings.TrimSpace(s), "*** Begin Patch") } + +// patchHeaders introduce the files a patch touches. +var patchHeaders = []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} + +// patchPaths are the files an apply_patch call names, made absolute against +// cwd so they equal the transcript's fileEdits paths, which the dispatcher +// dedupes written paths by. Non-patch arguments yield nil: record.WritePaths +// reads their path keys instead. +func patchPaths(raw json.RawMessage, cwd string) []string { + var s string + if json.Unmarshal(raw, &s) != nil || !isPatch(s) { + return nil + } + var out []string + seen := map[string]bool{} + for _, line := range strings.Split(s, "\n") { + var p string + for _, h := range patchHeaders { + if rest, ok := strings.CutPrefix(line, h); ok { + p = strings.TrimSpace(rest) + break + } + } + if p == "" { + continue + } + if cwd != "" && !filepath.IsAbs(p) { + p = filepath.Join(cwd, p) + } + p = filepath.Clean(p) + if !seen[p] { + seen[p] = true + out = append(out, p) + } + } + return out +} diff --git a/internal/hooks/copilot/transcript.go b/internal/hooks/copilot/transcript.go index d0c63d3..08c7149 100644 --- a/internal/hooks/copilot/transcript.go +++ b/internal/hooks/copilot/transcript.go @@ -20,6 +20,12 @@ import ( // message it paid for, so a turn's usage is their sum. Older releases put // outputTokens on assistant.message instead; that is read when no // usage_record was seen. +// +// Since the postToolUse hook delivers each call as it completes, the items +// read here are mostly the dispatcher's dedupe material at turn end: their +// arguments take the same shape as the hook's (toolArguments) so the two +// hash alike, and fileEdits give the written paths the hook's patch paths +// are matched against. Calls the hook did not report stay as items. type reader struct{} // Position is the line count. @@ -146,7 +152,7 @@ func (reader) Read(path string, from int) (*hooks.Turn, int, error) { } tools[id] = len(t.Items) t.Items = append(t.Items, hooks.Item{Tool: &record.Tool{ - NativeID: id, Name: te.ToolName, Arguments: te.Arguments, Status: "running", + NativeID: id, Name: te.ToolName, Arguments: toolArguments(te.Arguments), Status: "running", }}) case "tool.execution_complete": var te toolEvent @@ -163,7 +169,7 @@ func (reader) Read(path string, from int) (*hooks.Turn, int, error) { // the call still counts, with what the completion says. i = len(t.Items) tools[id] = i - t.Items = append(t.Items, hooks.Item{Tool: &record.Tool{NativeID: id, Name: te.ToolName, Arguments: te.Arguments}}) + t.Items = append(t.Items, hooks.Item{Tool: &record.Tool{NativeID: id, Name: te.ToolName, Arguments: toolArguments(te.Arguments)}}) } tool := t.Items[i].Tool tool.Status = "completed" diff --git a/internal/hooks/copilot/transcript_test.go b/internal/hooks/copilot/transcript_test.go index 6524bd8..73f3ce5 100644 --- a/internal/hooks/copilot/transcript_test.go +++ b/internal/hooks/copilot/transcript_test.go @@ -144,6 +144,9 @@ func TestReadCurrentReleaseShape(t *testing.T) { tool.Result == nil || !strings.HasPrefix(*tool.Result, `{"content":"Added 1 file(s)`) { t.Fatalf("%+v", tool) } + if string(tool.Arguments) != `{"patch":"*** Begin Patch\n*** Add File: note.txt\n+hello\n*** End Patch\n"}` { + t.Fatalf("the bare patch string takes the hook payload's shape: %s", tool.Arguments) + } if turn.Items[1].Tool != nil || turn.Items[1].Body != "Created note.txt." { t.Fatalf("an empty-content message is no item; the final one is: %+v", turn.Items[1]) } diff --git a/internal/hooks/cursor/cursor.go b/internal/hooks/cursor/cursor.go index 0ec12b1..5412f5e 100644 --- a/internal/hooks/cursor/cursor.go +++ b/internal/hooks/cursor/cursor.go @@ -3,12 +3,15 @@ // under ~/.cursor/projects (docs/research/entire-cli.md §3). // // Observed live against cursor-agent 2026.10.01: the interactive TUI fires -// sessionStart (at the first prompt, not at launch), beforeSubmitPrompt, -// stop and sessionEnd, so turns are captured as they end. Print mode -// (cursor-agent -p) fires only sessionStart, the tool hooks and sessionEnd: -// no beforeSubmitPrompt and no stop, so a -p run yields a session with no -// turns. The TUI keeps a hook's stdin open; ReadPayload returns after one -// JSON value, so Atlas never blocks it. +// sessionStart (at the first prompt, not at launch), beforeSubmitPrompt, the +// tool hooks afterFileEdit and afterShellExecution as each call completes, +// stop and sessionEnd. The prompt opens the turn, each tool hook records its +// call into the open turn, and stop closes it with the transcript's text. +// Print mode (cursor-agent -p) fires only sessionStart, the tool hooks and +// sessionEnd: no beforeSubmitPrompt and no stop, so a -p run yields a +// session with no turns until sessionEnd captures the whole transcript as +// one. The TUI keeps a hook's stdin open; ReadPayload returns after one JSON +// value, so Atlas never blocks it. package cursor import ( @@ -23,6 +26,7 @@ import ( "regexp" "strings" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/capture/store" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -37,11 +41,13 @@ type Agent struct{} // Hook verbs, as the installed commands name them. const ( - VerbSessionStart = "session-start" - VerbBeforePrompt = "before-submit-prompt" - VerbStop = "stop" - VerbSessionEnd = "session-end" - VerbPreCompact = "pre-compact" + VerbSessionStart = "session-start" + VerbBeforePrompt = "before-submit-prompt" + VerbAfterFileEdit = "after-file-edit" + VerbAfterShell = "after-shell-execution" + VerbStop = "stop" + VerbSessionEnd = "session-end" + VerbPreCompact = "pre-compact" ) // hook is one Cursor hook event and the verb Atlas runs for it. @@ -51,10 +57,15 @@ type hook struct { } // installed are the hooks Atlas writes, in lifecycle order. Cursor hooks -// take no matcher; the stdin payload is the same shape for every event. +// take no matcher; the stdin payload shares its common fields across events. +// Cursor has no generic post-tool hook: afterFileEdit and afterShellExecution +// are the two that name a completed call (afterMCPExecution is left to the +// transcript). var installed = []hook{ {"sessionStart", VerbSessionStart}, {"beforeSubmitPrompt", VerbBeforePrompt}, + {"afterFileEdit", VerbAfterFileEdit}, + {"afterShellExecution", VerbAfterShell}, {"stop", VerbStop}, {"sessionEnd", VerbSessionEnd}, {"preCompact", VerbPreCompact}, @@ -294,6 +305,7 @@ func (Agent) ResumeCommand(id string) string { return "cursor-agent --resume " + // the first tool hook on; the IDE names it throughout. type payload struct { ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` // changes with each user message Model string `json:"model"` WorkspaceRoots []string `json:"workspace_roots"` TranscriptPath string `json:"transcript_path"` @@ -306,6 +318,72 @@ type payload struct { OutputTokens *json.Number `json:"output_tokens"` CacheReadTokens *json.Number `json:"cache_read_tokens"` CacheWriteTokens *json.Number `json:"cache_write_tokens"` + // afterFileEdit: the absolute path and the replacements applied to it. A + // new file arrives as one edit with an empty old_string. + FilePath string `json:"file_path"` + Edits []edit `json:"edits"` + // afterShellExecution: the command and, when Cursor captured it, its + // output. + Command string `json:"command"` + Output *string `json:"output"` +} + +// edit is one replacement of an afterFileEdit payload. +type edit struct { + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + +// nativeID names a hook-reported call: Cursor's tool hooks carry no call id +// (and its transcript gives tool_use blocks none either), so the id is the +// generation plus a hash of what the call did. The dispatcher matches the +// transcript's copy by written path or command text, not by this id. +func (p payload) nativeID(kind string, parts ...string) string { + h := store.SHA256Hex([]byte(strings.Join(parts, "\x00")))[:16] + if p.GenerationID == "" { + return kind + "-" + h + } + return p.GenerationID + "-" + kind + "-" + h +} + +// editTool is the completed call an afterFileEdit payload describes, in the +// shape Cursor's transcript gives the same call so the dispatcher's keys +// line up: a new file is Write {path, contents}; anything else is Edit +// {path, edits} with the single replacement also flattened to old_string and +// new_string so the recorder can synthesise its patch. +func (p payload) editTool() *record.Tool { + if p.FilePath == "" { + return nil + } + edits, _ := json.Marshal(p.Edits) + t := &record.Tool{NativeID: p.nativeID("edit", p.FilePath, string(edits)), Status: "completed"} + if len(p.Edits) == 1 && p.Edits[0].OldString == "" { + t.Name = "Write" + t.Arguments = hooks.Raw(map[string]any{"path": p.FilePath, "contents": p.Edits[0].NewString}) + return t + } + t.Name = "Edit" + args := map[string]any{"path": p.FilePath, "edits": p.Edits} + if len(p.Edits) == 1 { + args["old_string"], args["new_string"] = p.Edits[0].OldString, p.Edits[0].NewString + } + t.Arguments = hooks.Raw(args) + return t +} + +// shellTool is the completed call an afterShellExecution payload describes: +// Shell {command}, as the transcript names it, with the output as its result. +func (p payload) shellTool() *record.Tool { + if strings.TrimSpace(p.Command) == "" { + return nil + } + out := "" + if p.Output != nil { + out = *p.Output + } + t := &record.Tool{NativeID: p.nativeID("shell", p.Command, out), Name: "Shell", Status: "completed", + Arguments: hooks.Raw(map[string]string{"command": p.Command}), Result: p.Output} + return t } func (p payload) cwd() string { @@ -373,6 +451,16 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { case VerbBeforePrompt: ev.Kind = hooks.TurnStart ev.Prompt = p.Prompt + case VerbAfterFileEdit: + ev.Kind = hooks.ToolUse + if ev.Tool = p.editTool(); ev.Tool == nil { + return nil, nil + } + case VerbAfterShell: + ev.Kind = hooks.ToolUse + if ev.Tool = p.shellTool(); ev.Tool == nil { + return nil, nil + } case VerbStop: ev.Kind = hooks.TurnEnd ev.Usage = p.usage() diff --git a/internal/hooks/cursor/cursor_test.go b/internal/hooks/cursor/cursor_test.go index aa6c46a..35d85cd 100644 --- a/internal/hooks/cursor/cursor_test.go +++ b/internal/hooks/cursor/cursor_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -43,7 +44,7 @@ func TestInstallIntoEmptyProject(t *testing.T) { if string(obj["version"]) != "1" { t.Fatalf("version %s", obj["version"]) } - if len(hs) != 5 { + if len(hs) != 7 { t.Fatalf("events %v", hs) } for _, h := range installed { @@ -105,8 +106,9 @@ func TestInstallKeepsUserHooksAndPrunesStale(t *testing.T) { if ss[1]["command"] != command(VerbSessionStart) { t.Fatalf("stale entry not replaced: %v", ss[1]) } - if len(hs["afterFileEdit"]) != 1 || hs["afterFileEdit"][0]["command"] != "./lint.sh" { - t.Fatalf("user event lost: %v", hs["afterFileEdit"]) + // Atlas installs into afterFileEdit too: the user's entry stays first. + if fe := hs["afterFileEdit"]; len(fe) != 2 || fe[0]["command"] != "./lint.sh" || fe[1]["command"] != command(VerbAfterFileEdit) { + t.Fatalf("user event lost or not appended to: %v", hs["afterFileEdit"]) } if _, ok := hs["preToolUse"]; ok { t.Fatalf("stale Atlas-only event kept: %v", hs["preToolUse"]) @@ -135,7 +137,7 @@ func TestInstallKeepsUserHooksAndPrunesStale(t *testing.T) { t.Fatal(err) } obj, hs = readHooks(t, root) - if len(hs) != 2 || len(hs["sessionStart"]) != 1 || hs["sessionStart"][0]["command"] != "echo mine" || len(hs["afterFileEdit"]) != 1 { + if len(hs) != 2 || len(hs["sessionStart"]) != 1 || hs["sessionStart"][0]["command"] != "echo mine" || len(hs["afterFileEdit"]) != 1 || hs["afterFileEdit"][0]["command"] != "./lint.sh" { t.Fatalf("after uninstall %v", hs) } if string(obj["version"]) != "1" { @@ -261,6 +263,47 @@ func TestParseLiveCLIPayloads(t *testing.T) { t.Fatalf("beforeSubmitPrompt %+v %v", ev, err) } + // The tool hooks, recorded from the same CLI: a new file, a replacement + // in it, and a shell command with its output. + ev, err = a.Parse(VerbAfterFileEdit, []byte(`{"conversation_id":"`+id+`","generation_id":"9c3d906a-6fae-4aca-8800-640b0373b6c2","model":"default","file_path":"/private/tmp/hooks-cursor/probe.txt","edits":[{"old_string":"","new_string":"alpha\n"}],`+tail+`,"hook_event_name":"afterFileEdit","transcript_path":"`+named+`"}`)) + if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil || ev.Transcript != named || ev.SessionID != id { + t.Fatalf("afterFileEdit %+v %v", ev, err) + } + w := ev.Tool + if w.Name != "Write" || w.Status != "completed" || string(w.Arguments) != `{"contents":"alpha\n","path":"/private/tmp/hooks-cursor/probe.txt"}` || !strings.HasPrefix(w.NativeID, "9c3d906a-6fae-4aca-8800-640b0373b6c2-edit-") { + t.Fatalf("new file %+v %s", w, w.Arguments) + } + if got := record.WritePaths(w.Arguments, nil); len(got) != 1 || got[0] != "/private/tmp/hooks-cursor/probe.txt" { + t.Fatalf("write paths %q", got) + } + ev, err = a.Parse(VerbAfterFileEdit, []byte(`{"conversation_id":"`+id+`","generation_id":"9c3d906a-6fae-4aca-8800-640b0373b6c2","model":"default","file_path":"/private/tmp/hooks-cursor/probe.txt","edits":[{"old_string":"alpha","new_string":"beta"}],`+tail+`,"hook_event_name":"afterFileEdit","transcript_path":"`+named+`"}`)) + if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil { + t.Fatalf("afterFileEdit (edit) %+v %v", ev, err) + } + if e := ev.Tool; e.Name != "Edit" || string(e.Arguments) != `{"edits":[{"old_string":"alpha","new_string":"beta"}],"new_string":"beta","old_string":"alpha","path":"/private/tmp/hooks-cursor/probe.txt"}` { + t.Fatalf("edit %+v %s", e, e.Arguments) + } + if record.CanonicalToolName(ev.Tool.Name, "", "", ev.Tool.Arguments) != "Edit" || record.EditPatch(ev.Tool.Arguments, "") == "" { + t.Fatalf("edit should canonicalise to Edit with a patch: %s", ev.Tool.Arguments) + } + ev, err = a.Parse(VerbAfterShell, []byte(`{"conversation_id":"`+id+`","generation_id":"9c3d906a-6fae-4aca-8800-640b0373b6c2","model":"default","command":"cat probe.txt","output":"beta\n","duration":13261.171,"sandbox":false,`+tail+`,"hook_event_name":"afterShellExecution","transcript_path":"`+named+`"}`)) + if err != nil || ev.Kind != hooks.ToolUse || ev.Tool == nil { + t.Fatalf("afterShellExecution %+v %v", ev, err) + } + if sh := ev.Tool; sh.Name != "Shell" || string(sh.Arguments) != `{"command":"cat probe.txt"}` || sh.Result == nil || *sh.Result != "beta\n" || sh.Status != "completed" || !strings.HasPrefix(sh.NativeID, "9c3d906a-6fae-4aca-8800-640b0373b6c2-shell-") { + t.Fatalf("shell %+v %s", sh, sh.Arguments) + } + if record.CanonicalToolName(ev.Tool.Name, "", "", ev.Tool.Arguments) != "Bash" { + t.Fatal("shell should canonicalise to Bash") + } + // A tool hook without its call (nothing edited, nothing run) is no event. + if ev, err := a.Parse(VerbAfterFileEdit, []byte(`{"conversation_id":"`+id+`",`+tail+`}`)); ev != nil || err != nil { + t.Fatalf("empty afterFileEdit %+v %v", ev, err) + } + if ev, err := a.Parse(VerbAfterShell, []byte(`{"conversation_id":"`+id+`","command":"",`+tail+`}`)); ev != nil || err != nil { + t.Fatalf("empty afterShellExecution %+v %v", ev, err) + } + ev, err = a.Parse(VerbStop, []byte(`{"conversation_id":"`+id+`","generation_id":"055d3bc7","model":"default","status":"completed","loop_count":0,"input_tokens":31219,"output_tokens":230,"cache_read_tokens":15488,"cache_write_tokens":0,`+tail+`,"hook_event_name":"stop","transcript_path":"`+named+`"}`)) if err != nil || ev.Kind != hooks.TurnEnd || ev.TurnEndFailed || ev.Transcript != named || ev.Usage == nil { t.Fatalf("stop %+v %v", ev, err) @@ -329,7 +372,7 @@ func TestIdentity(t *testing.T) { if a.Response(hooks.SessionStart, "hello") != nil { t.Fatal("Cursor shows no banner") } - if len(a.Verbs()) != 5 { + if len(a.Verbs()) != 7 { t.Fatal(a.Verbs()) } if got, ok := hooks.Get(ID); !ok || got.ID() != ID { diff --git a/internal/hooks/cursor/perevent_test.go b/internal/hooks/cursor/perevent_test.go new file mode 100644 index 0000000..af0f7c0 --- /dev/null +++ b/internal/hooks/cursor/perevent_test.go @@ -0,0 +1,148 @@ +package cursor + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/tryatlas/atlas-cli/internal/hooks" +) + +// TestPerEventToolDelivery drives the dispatcher through the hooks Cursor's +// TUI fires for one turn: the prompt opens the turn, afterFileEdit and +// afterShellExecution deliver their calls as they complete, and stop closes +// the turn from the transcript without recording those calls a second time. +// The transcript's tool_use blocks carry no id, so the match is by written +// path (Write, StrReplace) and by command text (Shell, whose transcript +// arguments carry extra fields). +func TestPerEventToolDelivery(t *testing.T) { + root := t.TempDir() + transcript := filepath.Join(t.TempDir(), "c.jsonl") + const id = "e00128c9-9da5-4ab7-8ad9-2743f63d3734" + common := `"conversation_id":"` + id + `","generation_id":"c2eb53ba","model":"default","workspace_roots":["` + root + `"],"transcript_path":"` + transcript + `"` + file := filepath.Join(root, "probe.txt") + + var deliveries []hooks.Delivery + d := &hooks.Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: func(_ context.Context, dl hooks.Delivery) error { + deliveries = append(deliveries, dl) + return nil + }} + a := Agent{} + run := func(verb, body string) { + t.Helper() + ev, err := a.Parse(verb, []byte(`{`+common+`,`+body+`}`)) + if err != nil { + t.Fatalf("%s: %v", verb, err) + } + if _, err := d.Run(context.Background(), a, ev); err != nil { + t.Fatalf("%s: %v", verb, err) + } + } + + if err := os.WriteFile(transcript, nil, 0o600); err != nil { + t.Fatal(err) + } + run(VerbSessionStart, `"hook_event_name":"sessionStart"`) + run(VerbBeforePrompt, `"prompt":"make probe.txt"`) + // The agent works: Cursor's Write, then its StrReplace, then a Shell. + _ = os.WriteFile(file, []byte("alpha\n"), 0o644) + run(VerbAfterFileEdit, `"file_path":"`+file+`","edits":[{"old_string":"","new_string":"alpha\n"}]`) + _ = os.WriteFile(file, []byte("beta\n"), 0o644) + run(VerbAfterFileEdit, `"file_path":"`+file+`","edits":[{"old_string":"alpha","new_string":"beta"}]`) + run(VerbAfterShell, `"command":"cat probe.txt","output":"beta\n","duration":12,"sandbox":false`) + // A command the hooks did not report stays with the transcript. + lines := []string{ + `{"role":"user","message":{"content":[{"type":"text","text":"make probe.txt"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"` + file + `","contents":"alpha\n"}}]}}`, + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"` + file + `","old_string":"alpha","new_string":"beta"}}]}}`, + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cat probe.txt","working_directory":"` + root + `","description":"Show it"}}]}}`, + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"echo unreported"}}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}`, + } + if err := os.WriteFile(transcript, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run(VerbStop, `"status":"completed","loop_count":0,"input_tokens":1000,"output_tokens":20,"cache_read_tokens":600,"cache_write_tokens":0`) + + var kinds []string + var tools []string + for _, dl := range deliveries { + switch { + case dl.Start: + kinds = append(kinds, "start") + case dl.Open != nil: + kinds = append(kinds, "open:"+dl.Open.Prompt) + case dl.Tool != nil: + kinds = append(kinds, "tool:"+dl.Tool.Name) + tools = append(tools, dl.Tool.NativeID) + case dl.Turn != nil: + kinds = append(kinds, "turn") + } + } + want := "start|open:make probe.txt|tool:Write|tool:Edit|tool:Shell|turn" + if got := strings.Join(kinds, "|"); got != want { + t.Fatalf("deliveries %s, want %s", got, want) + } + for i, id := range tools { + if !strings.HasPrefix(id, "c2eb53ba-") { + t.Fatalf("tool %d id %q lacks the generation", i, id) + } + for _, other := range tools[i+1:] { + if other == id { + t.Fatalf("two calls share id %q", id) + } + } + } + turn := deliveries[len(deliveries)-1].Turn + if !turn.Open || turn.Prompt != "make probe.txt" || turn.Usage == nil || turn.Usage.Output != 20 || turn.Usage.CacheRead != 600 { + t.Fatalf("turn %+v", turn) + } + var left []string + for _, it := range turn.Items { + if it.Tool != nil { + left = append(left, it.Tool.Name+":"+string(it.Tool.Arguments)) + } else { + left = append(left, it.Mode+":"+it.Body) + } + } + if len(left) != 2 || left[0] != `Shell:{"command":"echo unreported"}` || left[1] != "text:done" { + t.Fatalf("transcript items left after dedupe: %q", left) + } + if len(turn.Changed) != 0 { + t.Fatalf("the delivered Write explains probe.txt; changed = %q", turn.Changed) + } +} + +func TestEditAndShellToolShapes(t *testing.T) { + p := payload{GenerationID: "g1", FilePath: "/w/a.txt", Edits: []edit{{OldString: "", NewString: "x\n"}}} + w := p.editTool() + if w.Name != "Write" || string(w.Arguments) != `{"contents":"x\n","path":"/w/a.txt"}` || w.Status != "completed" { + t.Fatalf("write %+v %s", w, w.Arguments) + } + p.Edits = []edit{{OldString: "x", NewString: "y"}} + e := p.editTool() + if e.Name != "Edit" || !strings.Contains(string(e.Arguments), `"old_string":"x"`) || !strings.Contains(string(e.Arguments), `"edits":[{"old_string":"x","new_string":"y"}]`) { + t.Fatalf("edit %+v %s", e, e.Arguments) + } + if e.NativeID == w.NativeID || !strings.HasPrefix(e.NativeID, "g1-edit-") { + t.Fatalf("ids %q %q", w.NativeID, e.NativeID) + } + p.Edits = []edit{{OldString: "a", NewString: "b"}, {OldString: "c", NewString: "d"}} + if m := p.editTool(); m.Name != "Edit" || strings.Count(string(m.Arguments), `"old_string"`) != 2 { + t.Fatalf("multi-edit flattened: %s", m.Arguments) + } + if (payload{}).editTool() != nil || (payload{Command: " "}).shellTool() != nil { + t.Fatal("empty payloads should give no tool") + } + out := "ok\n" + s := payload{GenerationID: "g1", Command: "ls", Output: &out}.shellTool() + if s.Name != "Shell" || string(s.Arguments) != `{"command":"ls"}` || s.Result == nil || *s.Result != "ok\n" || !strings.HasPrefix(s.NativeID, "g1-shell-") { + t.Fatalf("shell %+v", s) + } + if n := (payload{Command: "ls"}).shellTool(); n.Result != nil || !strings.HasPrefix(n.NativeID, "shell-") { + t.Fatalf("no output, no generation: %+v", n) + } +} diff --git a/internal/hooks/cursor/state_main_test.go b/internal/hooks/cursor/state_main_test.go new file mode 100644 index 0000000..68818f7 --- /dev/null +++ b/internal/hooks/cursor/state_main_test.go @@ -0,0 +1,15 @@ +package cursor + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/deliver.go b/internal/hooks/deliver.go index 681eb71..641d103 100644 --- a/internal/hooks/deliver.go +++ b/internal/hooks/deliver.go @@ -16,6 +16,7 @@ import ( "sort" "time" + gitlink "github.com/tryatlas/atlas-cli/internal/capture/git" "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/capture/store" "github.com/tryatlas/atlas-cli/internal/listener" @@ -34,8 +35,10 @@ var ErrSpooled = errors.New("spooled") // SpoolDir holds deliveries waiting for a writer. func SpoolDir(root string) string { return filepath.Join(Dir(root), "spool") } -// Deliver is the default Dispatcher.Deliver. -func Deliver(version string) func(ctx context.Context, d Delivery) error { +// Deliver is the default Dispatcher.Deliver. afterDirect, when set, runs +// after a direct write with the store still open: the caller syncs the new +// rows to the cloud there, so a turn does not wait for the next atlas serve. +func Deliver(version string, afterDirect func(ctx context.Context, st *store.Store, d Delivery)) func(ctx context.Context, d Delivery) error { return func(ctx context.Context, d Delivery) error { if rf := listener.ReadRunFile(d.Root); rf != nil { if err := postHook(ctx, rf.Socket, d); err == nil { @@ -44,7 +47,7 @@ func Deliver(version string) func(ctx context.Context, d Delivery) error { // The listener is there but did not answer: fall through to a // direct write, which fails on its lock and spools. } - err := applyDirect(ctx, version, d.Root, []Delivery{d}) + err := applyDirect(ctx, version, d.Root, []Delivery{d}, afterDirect) if errors.Is(err, store.ErrLocked) { if serr := spool(d); serr != nil { return serr @@ -59,6 +62,22 @@ func Deliver(version string) func(ctx context.Context, d Delivery) error { // listener and the direct path share it. func Apply(ctx context.Context, rec *record.Recorder, mem *server.Server, ds []Delivery) error { for _, d := range ds { + if err := apply1(ctx, rec, mem, d); err != nil { + return err + } + if d.LinkFrom != "" { + // Touches that landed after a commit the post-commit hook + // already walked: link the turn's commits now. + if _, err := gitlink.LinkRange(ctx, rec.Store, d.Root, d.LinkFrom, time.Now); err != nil { + return err + } + } + } + return nil +} + +func apply1(ctx context.Context, rec *record.Recorder, mem *server.Server, d Delivery) error { + { g := server.Grant{SessionID: d.Session.NativeID, Agent: d.Session.Agent, Cwd: d.Root} switch { case d.Start: @@ -69,12 +88,37 @@ func Apply(ctx context.Context, rec *record.Recorder, mem *server.Server, ds []D if mem != nil { mem.SessionEnded(ctx, g) } + case d.Open != nil: + if _, err := rec.OpenTurn(ctx, d.Session, d.Open.Prompt, d.Open.At); err != nil { + return err + } + case d.Tool != nil: + if err := rec.RecordTool(ctx, d.Session, *d.Tool, d.Changed, d.At); err != nil { + return err + } + if mem != nil { + one := []record.CapturedItem{{Tool: d.Tool}} + foldChanges(ctx, mem, g, d.Root, &record.CapturedTurn{Items: one, Changed: d.Changed}) + foldPlans(ctx, mem, g, one) + if d.Plan != nil { + _ = mem.Fold(ctx, g, memrecord.Event{Kind: "plan_set", Text: d.Plan.Text, Status: d.Plan.Status}) + } + } case d.Turn != nil: if _, err := rec.RecordTurn(ctx, d.Session, *d.Turn); err != nil { return err } if mem != nil { foldChanges(ctx, mem, g, d.Root, d.Turn) + foldPlans(ctx, mem, g, d.Turn.Items) + if d.Plan != nil { + _ = mem.Fold(ctx, g, memrecord.Event{Kind: "plan_set", Text: d.Plan.Text, Status: d.Plan.Status}) + } + for _, it := range d.Turn.Items { + if it.Tool == nil && it.Mode != "thinking" { + mem.Capture(ctx, g, it.Body) + } + } } } } @@ -114,7 +158,7 @@ func foldChanges(ctx context.Context, mem *server.Server, g server.Grant, root s // applyDirect takes the writer lock for the duration of the write, drains // the spool first, then applies ds. -func applyDirect(ctx context.Context, version, root string, ds []Delivery) error { +func applyDirect(ctx context.Context, version, root string, ds []Delivery, after func(ctx context.Context, st *store.Store, d Delivery)) error { lock, err := store.Lock(root) if err != nil { return err @@ -135,6 +179,11 @@ func applyDirect(ctx context.Context, version, root string, ds []Delivery) error return err } ClearLastError(root) + if after != nil { + for _, d := range ds { + after(ctx, st, d) + } + } return nil } diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index 802e339..fdf6c6d 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -1,7 +1,9 @@ package hooks import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -25,14 +27,34 @@ type Dispatcher struct { Banner string } -// Delivery is what a hook hands to the capture side: a session start, a -// turn, or a session end for one native session. +// Delivery is what a hook hands to the capture side for one native session: +// a session start, an opening prompt, one completed tool call, the turn's +// close (or a whole turn), or a session end. type Delivery struct { Root string `json:"root"` Session record.Session `json:"session"` Start bool `json:"start,omitempty"` + Open *OpenTurn `json:"open,omitempty"` + Tool *record.Tool `json:"tool,omitempty"` Turn *record.CapturedTurn `json:"turn,omitempty"` End bool `json:"end,omitempty"` + // Changed are Project-relative paths git saw change while Tool (a + // shell call) ran; they are recorded as its file touches. + Changed []string `json:"changed,omitempty"` + // At is when the hook fired, so a spooled delivery keeps its time. + At time.Time `json:"at,omitempty"` + // LinkFrom, when set, asks the writer to link the commits after it to + // the touches just recorded (commits made during the turn). + LinkFrom string `json:"link_from,omitempty"` + // Plan is the session's whole plan after an incremental task tool + // changed it, folded as plan_set. + Plan *PlanFold `json:"plan,omitempty"` +} + +// OpenTurn is the prompt that opens a turn as it is submitted. +type OpenTurn struct { + Prompt string `json:"prompt"` + At time.Time `json:"at"` } // Run handles one event. Every error is logged and swallowed by the caller; @@ -44,6 +66,13 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error if !ValidSessionID(ev.SessionID) { return nil, ErrBadSessionID } + // Hooks of one session can fire concurrently (parallel tool calls): + // one at a time per session, so the state's read-modify-write holds. + unlock, err := lockSession(d.Root, a.ID(), ev.SessionID) + if err != nil { + return nil, err + } + defer unlock() now := d.Now() st, err := LoadState(d.Root, a.ID(), ev.SessionID) if err != nil { @@ -66,7 +95,7 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error if err := st.Save(d.Root); err != nil { return nil, err } - _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Start: true}) + _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Start: true, At: now}) } switch ev.Kind { @@ -90,19 +119,77 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error } st.InTurn = true st.Prompt = ev.Prompt - st.ToolFiles = nil + st.ToolFiles, st.Delivered, st.Tools, st.Open = nil, nil, nil, false st.Status = gitStatus(ctx, d.Root) + st.Head = gitHead(ctx, d.Root) + st.TurnHead = st.Head if r := a.Reader(); r != nil && st.Transcript != "" { if pos, err := r.Position(st.Transcript); err == nil { st.Offset = pos } } + if ev.Prompt != "" { + // The prompt opens the turn now; tool calls follow as they + // complete, and the reply closes it (per-event capture). + st.Open = true + if err := st.Save(d.Root); err != nil { + return nil, err + } + return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Open: &OpenTurn{Prompt: ev.Prompt, At: now}, At: now}) + } return nil, st.Save(d.Root) case ToolUse: for _, f := range ev.Files { st.ToolFiles = append(st.ToolFiles, relPath(d.Root, f)) } + if ev.Tool != nil && ev.Tool.NativeID != "" { + if !st.Open { + st.Tools = append(st.Tools, *ev.Tool) + _ = trackTasks(st, *ev.Tool) // the turn-end delivery folds the plan + return nil, st.Save(d.Root) + } + st.Delivered = append(st.Delivered, toolKeys(*ev.Tool)) + for _, p := range writtenPaths(*ev.Tool) { + rel, _ := record.ProjectPath(d.Root, p) + st.Delivered = append(st.Delivered, []string{"path:" + rel}) // explained: not a shell change + } + dl := Delivery{Root: d.Root, Session: sess, Tool: ev.Tool, At: now} + dl.Plan = trackTasks(st, *ev.Tool) + if record.CanonicalToolName(ev.Tool.Name, ev.Tool.Title, ev.Tool.Kind, ev.Tool.Arguments) == "Bash" && st.Status != nil { + // What the shell command changed is known now: its touches, + // and the baseline for the rest of the turn moves past them. + after := gitStatus(ctx, d.Root) + head := gitHead(ctx, d.Root) + explained := explainedPaths(st.Delivered) + changed := changedSince(d.Root, st.Status, after) + if head != "" && head != st.Head { + // The command committed: what it committed is no longer + // in git status but is its work all the same. + changed = append(changed, committedSince(ctx, d.Root, st.Head)...) + } + for _, p := range changed { + if !explained[p] { + dl.Changed = append(dl.Changed, p) + st.Delivered = append(st.Delivered, []string{"path:" + p}) + explained[p] = true + } + } + if after != nil { + st.Status = after + } + if head != "" { + st.Head = head + } + } + if st.TurnHead != "" && gitHead(ctx, d.Root) != st.TurnHead { + dl.LinkFrom = st.TurnHead + } + if err := st.Save(d.Root); err != nil { + return nil, err + } + return nil, d.deliver(ctx, st, dl) + } return nil, st.Save(d.Root) case TurnEnd: @@ -112,15 +199,34 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error return nil, err } st.InTurn, st.Prompt, st.ToolFiles, st.Status = false, "", nil, nil + st.Open, st.Delivered, st.Tools = false, nil, nil if turn == nil { return nil, st.Save(d.Root) } st.Turn++ + linkFrom := "" + if st.TurnHead != "" && gitHead(ctx, d.Root) != st.TurnHead { + linkFrom = st.TurnHead // commits made during the turn + } + st.Head, st.TurnHead = "", "" + // Task tool calls the hooks did not deliver (or that came before + // the turn opened) still build the plan, once each. + var plan *PlanFold + for _, it := range turn.Items { + if it.Tool != nil { + if p := trackTasks(st, *it.Tool); p != nil { + plan = p + } + } + } + if plan == nil && len(st.Tasks) > 0 && hadTaskCalls(turn.Items) { + plan = planFromTasks(st.Tasks) + } if err := st.Save(d.Root); err != nil { return nil, err } sess.Model = st.Model - return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn}) + return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn, At: now, LinkFrom: linkFrom, Plan: plan}) case Compaction: return nil, st.Save(d.Root) @@ -133,7 +239,7 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error if turn, err := d.turnEnd(ctx, a, st, ev); err == nil && turn != nil { st.Turn = 1 sess.Model = st.Model - _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn}) + _ = d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, Turn: turn, At: now}) } } // The state stays (atlas agents sessions lists it with its resume @@ -142,7 +248,7 @@ func (d *Dispatcher) Run(ctx context.Context, a Agent, ev *Event) ([]byte, error if err := st.Save(d.Root); err != nil { return nil, err } - return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, End: true}) + return nil, d.deliver(ctx, st, Delivery{Root: d.Root, Session: sess, End: true, At: now}) case SubagentStart, SubagentEnd: return nil, st.Save(d.Root) @@ -185,14 +291,24 @@ func (d *Dispatcher) turnEnd(ctx context.Context, a Agent, st *State, ev *Event) if ev.Model != "" { turn.Model = ev.Model } + // Tool calls already delivered during the open turn are not recorded + // again from the transcript; calls the hooks saw while no turn was open + // join now. + if st.Open { + turn.Open = true + turn.Items = dropDelivered(turn.Items, st.Delivered) + } + for i := range st.Tools { + turn.Items = append(turn.Items, record.CapturedItem{Tool: &st.Tools[i]}) + } // Files the agent's tool hooks named, then whatever git says changed - // during the turn that no tool call explains. - explained := map[string]bool{} + // during the turn that no tool call explains (delivered ones included). + explained := explainedPaths(st.Delivered) for _, it := range turn.Items { if it.Tool == nil { continue } - for _, p := range record.WritePaths(it.Tool.Arguments, it.Tool.Locations, it.Tool.Diffs...) { + for _, p := range writtenPaths(*it.Tool) { rel, _ := record.ProjectPath(d.Root, p) explained[rel] = true } @@ -204,18 +320,179 @@ func (d *Dispatcher) turnEnd(ctx context.Context, a Agent, st *State, ev *Event) } } if st.Status != nil { - for _, p := range changedSince(d.Root, st.Status, gitStatus(ctx, d.Root)) { + changed := changedSince(d.Root, st.Status, gitStatus(ctx, d.Root)) + if head := gitHead(ctx, d.Root); head != "" && st.Head != "" && head != st.Head { + changed = append(changed, committedSince(ctx, d.Root, st.Head)...) + } + for _, p := range changed { if !explained[p] { turn.Changed = append(turn.Changed, p) + explained[p] = true } } } - if turn.Prompt == "" && len(turn.Items) == 0 && len(turn.Changed) == 0 && !ev.TurnEndFailed { + if turn.Prompt == "" && len(turn.Items) == 0 && len(turn.Changed) == 0 && !ev.TurnEndFailed && !turn.Open { return nil, nil // nothing happened (a turn that only read) } return turn, nil } +// toolKeys identifies a tool call across the hook payload and the +// transcript, whose ids and argument shapes may differ: the agent's call +// id; the canonical name with a hash of the arguments (unless they are +// empty); for a shell call its command text (a string, or an argv array +// joined); for a call that writes files, each written path. +func toolKeys(t record.Tool) []string { + keys := []string{"id:" + t.NativeID} + name := record.CanonicalToolName(t.Name, t.Title, t.Kind, t.Arguments) + if len(bytes.TrimSpace(t.Arguments)) > 0 { + var compact bytes.Buffer + if json.Compact(&compact, t.Arguments) == nil && compact.String() != "{}" && compact.String() != "null" { + keys = append(keys, "args:"+strings.ToLower(name)+":"+store.SHA256Hex(compact.Bytes())) + } + var m struct { + Command json.RawMessage `json:"command"` + Cmd json.RawMessage `json:"cmd"` + } + if json.Unmarshal(t.Arguments, &m) == nil { + if c := commandText(m.Command) + commandText(m.Cmd); c != "" { + keys = append(keys, "cmd:"+store.SHA256Hex([]byte(c))) + } + } + } + if record.WritesFiles(name) || len(t.Diffs) > 0 { + for _, p := range record.WritePaths(t.Arguments, t.Locations, t.Diffs...) { + keys = append(keys, "write:"+filepath.ToSlash(filepath.Clean(p))) + } + } + return keys +} + +// commandText flattens a command argument: a string, or an argv array. +func commandText(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return strings.TrimSpace(s) + } + var argv []string + if json.Unmarshal(raw, &argv) == nil { + return strings.TrimSpace(strings.Join(argv, " ")) + } + return "" +} + +// explainedPaths are the Project paths delivered tool calls wrote. +func explainedPaths(delivered [][]string) map[string]bool { + out := map[string]bool{} + for _, g := range delivered { + for _, k := range g { + if rel, ok := strings.CutPrefix(k, "path:"); ok { + out[rel] = true + } + } + } + return out +} + +// dropDelivered removes the transcript's tool items already recorded from a +// hook, each delivered call matching at most one item. When the agent's hook +// ids match its transcript ids (any item's id was delivered) ids decide +// alone, so two distinct calls with the same command stay distinct; an item +// matched by id is kept, marked NoSample, so its result completes the row. +// Otherwise an item matches a delivered call by arguments, by command text, +// or by every path it wrote, and is dropped. +func dropDelivered(items []record.CapturedItem, delivered [][]string) []record.CapturedItem { + groups := make([][]string, 0, len(delivered)) + for _, g := range delivered { + if len(g) > 0 && !strings.HasPrefix(g[0], "path:") { + groups = append(groups, g) + } + } + has := func(g []string, key string) bool { + for _, k := range g { + if k == key { + return true + } + } + return false + } + idsMatch := false + for _, it := range items { + if it.Tool == nil { + continue + } + for _, g := range groups { + if has(g, "id:"+it.Tool.NativeID) { + idsMatch = true + } + } + } + match := func(t record.Tool) int { + keys := toolKeys(t) + for i, g := range groups { + if has(g, keys[0]) { + return i + } + } + if idsMatch { + return -1 + } + for i, g := range groups { + for _, k := range keys[1:] { + if !strings.HasPrefix(k, "write:") && has(g, k) { + return i + } + } + } + // Every written path delivered (possibly by several calls): a + // duplicate too; the first writing group is consumed. + writes, first := 0, -1 + for _, k := range keys { + if !strings.HasPrefix(k, "write:") { + continue + } + found := -1 + for i, g := range groups { + if has(g, k) { + found = i + break + } + } + if found < 0 { + return -1 + } + writes++ + if first < 0 { + first = found + } + } + if writes > 0 { + return first + } + return -1 + } + kept := items[:0] + for _, it := range items { + if it.Tool != nil { + if i := match(*it.Tool); i >= 0 { + byID := has(groups[i], "id:"+it.Tool.NativeID) + groups = append(groups[:i], groups[i+1:]...) + if !byID { + continue + } + // Same row: let the transcript complete it (result, status) + // without sampling its writes again. + it.Tool.NoSample = true + } + } + kept = append(kept, it) + } + return kept +} + // deliver hands off to the store; a failure spools (deliver.go) and is // reported once in the hooks log. func (d *Dispatcher) deliver(ctx context.Context, st *State, dl Delivery) error { @@ -245,3 +522,14 @@ func Enabled(ctx context.Context, root string) bool { b, err := st.Binding(ctx) return err == nil && b != nil && b.Enabled } + +// writtenPaths are the files a tool call wrote. A call that only read or +// searched names files too (its locations), but explains no change: a file +// the agent read and then committed is the commit's change. +func writtenPaths(t record.Tool) []string { + name := record.CanonicalToolName(t.Name, t.Title, t.Kind, t.Arguments) + if !record.WritesFiles(name) && len(t.Diffs) == 0 { + return nil + } + return record.WritePaths(t.Arguments, t.Locations, t.Diffs...) +} diff --git a/internal/hooks/droid/droid.go b/internal/hooks/droid/droid.go index 3a1917c..6427ff6 100644 --- a/internal/hooks/droid/droid.go +++ b/internal/hooks/droid/droid.go @@ -4,12 +4,15 @@ package droid import ( + "bytes" "encoding/json" "os" "os/exec" "path/filepath" "strings" + "github.com/tryatlas/atlas-cli/internal/capture/record" + "github.com/tryatlas/atlas-cli/internal/capture/store" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -28,6 +31,7 @@ const ( VerbStop = "stop" VerbPreCompact = "pre-compact" VerbSessionEnd = "session-end" + VerbPostToolUse = "post-tool-use" ) // MissingMessage is what Droid shows when the hook is installed but atlas is @@ -45,7 +49,7 @@ func (Agent) Binary() string { return "droid" } // Verbs are the hook verbs the installed config invokes. func (Agent) Verbs() []string { - return []string{VerbSessionStart, VerbPromptSubmit, VerbStop, VerbPreCompact, VerbSessionEnd} + return []string{VerbSessionStart, VerbPromptSubmit, VerbPostToolUse, VerbStop, VerbPreCompact, VerbSessionEnd} } // SettingsPath is Droid's project settings file, which holds its hooks. It @@ -63,6 +67,9 @@ func (Agent) shaped(root string) hooks.ClaudeShaped { {Type: "SessionStart", Command: hooks.WrapWithMessage(hooks.Command(ID, VerbSessionStart), MissingMessage)}, {Type: "SessionStart", Command: promptSubmit}, {Type: "UserPromptSubmit", Command: promptSubmit}, + // Matcher "" is every tool: each completed call is recorded into the + // open turn as it happens (per-event delivery). + {Type: "PostToolUse", Command: hooks.Wrap(hooks.Command(ID, VerbPostToolUse))}, {Type: "Stop", Command: hooks.Wrap(hooks.Command(ID, VerbStop))}, {Type: "PreCompact", Command: hooks.Wrap(hooks.Command(ID, VerbPreCompact))}, {Type: "SessionEnd", Command: hooks.Wrap(hooks.Command(ID, VerbSessionEnd))}, @@ -102,13 +109,47 @@ func (Agent) Response(k hooks.Kind, message string) []byte { func (Agent) ResumeCommand(id string) string { return "droid --session-id " + id } // payload is the common shape of Droid hook input. Stop omits model, and -// the SessionStart-fired user-prompt-submit omits prompt. +// the SessionStart-fired user-prompt-submit omits prompt. PostToolUse +// carries the Claude-style tool fields (Entire's factoryaidroid/types.go +// postToolHookInputRaw). type payload struct { - SessionID string `json:"session_id"` - TranscriptPath string `json:"transcript_path"` - Cwd string `json:"cwd"` - Model string `json:"model"` - Prompt string `json:"prompt"` + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + Model string `json:"model"` + Prompt string `json:"prompt"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response"` +} + +// toolOf is the completed call a PostToolUse payload describes, keyed by +// the tool_use_id Droid shares with its transcript's tool_use block. Nil +// without an id or a name: Droid has been seen to omit tool_use_id (Entire +// keeps a fingerprint fallback for it), and such a call is left to the +// transcript at Stop. The response is a string or an object; either is +// kept as text. +func (p payload) toolOf() *record.Tool { + if p.ToolName == "" { + return nil + } + id := p.ToolUseID + if id == "" { + // Droid has shipped tool payloads without an id: derive a stable one + // from the call; the dispatcher still matches the transcript's call + // by arguments or written path. + id = "droid-" + store.SHA256Hex(append([]byte(p.ToolName+"\x00"), p.ToolInput...))[:16] + } + t := &record.Tool{NativeID: id, Name: p.ToolName, Arguments: p.ToolInput, Status: "completed"} + if r := bytes.TrimSpace(p.ToolResponse); len(r) > 0 && string(r) != "null" { + var str string + if json.Unmarshal(r, &str) != nil { + str = string(r) + } + t.Result = &str + } + return t } // Parse maps a hook verb and payload to an event. @@ -126,6 +167,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { case VerbPromptSubmit: ev.Kind = hooks.TurnStart ev.Prompt = strings.TrimSpace(p.Prompt) + case VerbPostToolUse: + ev.Kind = hooks.ToolUse + ev.Tool = p.toolOf() + if ev.Tool == nil { + return nil, nil + } case VerbStop: ev.Kind = hooks.TurnEnd if ev.Model == "" { diff --git a/internal/hooks/droid/droid_test.go b/internal/hooks/droid/droid_test.go index 75c996c..76fd015 100644 --- a/internal/hooks/droid/droid_test.go +++ b/internal/hooks/droid/droid_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" @@ -79,11 +80,14 @@ func TestInstallUninstall(t *testing.T) { if got := commands(hk["Stop"]); len(got) != 1 || got[0] != hooks.Wrap(hooks.Command(ID, VerbStop)) { t.Fatalf("Stop should hold only the current hook (stale pruned): %q", got) } - for typ, verb := range map[string]string{"UserPromptSubmit": VerbPromptSubmit, "PreCompact": VerbPreCompact, "SessionEnd": VerbSessionEnd} { + for typ, verb := range map[string]string{"UserPromptSubmit": VerbPromptSubmit, "PostToolUse": VerbPostToolUse, "PreCompact": VerbPreCompact, "SessionEnd": VerbSessionEnd} { if got := commands(hk[typ]); len(got) != 1 || got[0] != hooks.Wrap(hooks.Command(ID, verb)) { t.Fatalf("%s: %q", typ, got) } } + if post := hk["PostToolUse"]; len(post) != 1 || post[0].Matcher == nil || *post[0].Matcher != "" { + t.Fatalf("PostToolUse should match every tool with matcher \"\": %+v", post) + } if a.State(root) != hooks.Current { t.Fatalf("state after install: %v", a.State(root)) } @@ -171,11 +175,65 @@ func TestParse(t *testing.T) { if ev, err := a.Parse("notification", []byte(`{`+base+`}`)); err != nil || ev != nil { t.Fatalf("unknown verb: %v %v", ev, err) } + if !slices.Contains(a.Verbs(), VerbPostToolUse) { + t.Fatalf("verbs should include post-tool-use: %q", a.Verbs()) + } if _, err := a.Parse(VerbStop, []byte(`nope`)); err == nil { t.Fatal("bad JSON should error") } } +func TestParsePostToolUse(t *testing.T) { + a := Agent{} + base := `"session_id":"s-1","transcript_path":"/h/.factory/sessions/repo/s-1.jsonl","cwd":"/w","hook_event_name":"PostToolUse"` + // An object response is kept as its JSON text. + post := `{` + base + `,"tool_name":"Edit","tool_use_id":"toolu_01","tool_input":{"file_path":"/w/main.go","old_string":"a","new_string":"b"},"tool_response":{"filePath":"/w/main.go","success":true}}` + ev, err := a.Parse(VerbPostToolUse, []byte(post)) + if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.SessionID != "s-1" || ev.Transcript != "/h/.factory/sessions/repo/s-1.jsonl" || ev.Cwd != "/w" { + t.Fatalf("post-tool-use: %+v %v", ev, err) + } + tool := ev.Tool + if tool == nil || tool.NativeID != "toolu_01" || tool.Name != "Edit" || tool.Status != "completed" || + string(tool.Arguments) != `{"file_path":"/w/main.go","old_string":"a","new_string":"b"}` || + tool.Result == nil || *tool.Result != `{"filePath":"/w/main.go","success":true}` { + t.Fatalf("tool: %+v", tool) + } + // A string response is kept verbatim. + ev, err = a.Parse(VerbPostToolUse, []byte(`{`+base+`,"tool_name":"Execute","tool_use_id":"toolu_02","tool_input":{"command":"go test ./..."},"tool_response":"ok\n"}`)) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Result == nil || *ev.Tool.Result != "ok\n" || ev.Tool.NativeID != "toolu_02" { + t.Fatalf("string response: %+v %v", ev, err) + } + // No response at all, or a null one, leaves Result nil. + for _, tail := range []string{``, `,"tool_response":null`} { + ev, err = a.Parse(VerbPostToolUse, []byte(`{`+base+`,"tool_name":"Read","tool_use_id":"toolu_03","tool_input":{"file_path":"/w/x"}`+tail+`}`)) + if err != nil || ev == nil || ev.Tool == nil || ev.Tool.Result != nil { + t.Fatalf("no response (%q): %+v %v", tail, ev, err) + } + } + // Without a tool id (Droid has been seen to omit it) a stable id is + // derived from the call, so it still lands per event; the dispatcher + // matches the transcript's call by arguments or written path. + noID := `{` + base + `,"tool_name":"Edit","tool_input":{"file_path":"/w/x"},"tool_response":"ok"}` + ev, err = a.Parse(VerbPostToolUse, []byte(noID)) + if err != nil || ev == nil || ev.Tool == nil || !strings.HasPrefix(ev.Tool.NativeID, "droid-") || len(ev.Tool.NativeID) != 22 { + t.Fatalf("payload without an id: %+v %v", ev, err) + } + again, _ := a.Parse(VerbPostToolUse, []byte(noID)) + if again.Tool.NativeID != ev.Tool.NativeID { + t.Fatal("the derived id must be stable") + } + // Without a name there is nothing to record: nil, and the transcript + // supplies the call at Stop. + for _, raw := range []string{ + `{` + base + `,"tool_use_id":"toolu_04","tool_input":{}}`, + `{` + base + `}`, + } { + if ev, err := a.Parse(VerbPostToolUse, []byte(raw)); err != nil || ev != nil { + t.Fatalf("payload without a call should be nil: %s → %+v %v", raw, ev, err) + } + } +} + func TestStopModelFromSettings(t *testing.T) { dir := t.TempDir() transcript := filepath.Join(dir, "abc.jsonl") diff --git a/internal/hooks/droid/state_main_test.go b/internal/hooks/droid/state_main_test.go new file mode 100644 index 0000000..be94557 --- /dev/null +++ b/internal/hooks/droid/state_main_test.go @@ -0,0 +1,15 @@ +package droid + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/event.go b/internal/hooks/event.go index c14e61b..302243b 100644 --- a/internal/hooks/event.go +++ b/internal/hooks/event.go @@ -1,7 +1,7 @@ // Package hooks captures sessions run in an agent's own UI through the // agent's lifecycle hooks (ADR-0013). Each agent integration translates its // native hook payload into one Event; the dispatcher does the rest: session -// state under .atlas/hooks/, the transcript read from the offset recorded at +// state in the CLI state directory, the transcript read from the offset recorded at // turn start, the git status diff, and delivery to the capture store. package hooks @@ -13,6 +13,7 @@ import ( "os" "regexp" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/capture/store" ) @@ -63,13 +64,14 @@ type Event struct { Model string Usage *store.TokenTotals // a hook-provided usage total for the turn Files []string // ToolUse: files the tool changed + Tool *record.Tool // ToolUse: the completed call, when the payload carries it ToolUseID string // TurnEndFailed marks a turn that ended on an API error. TurnEndFailed bool } // sessionIDPattern is what a session id may look like before it becomes a -// file name under .atlas/hooks/. +// file name in the hooks state directory. var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$`) // ErrBadSessionID is a session id that cannot be a file name. diff --git a/internal/hooks/gitstatus.go b/internal/hooks/gitstatus.go index 3604f06..d0e4d13 100644 --- a/internal/hooks/gitstatus.go +++ b/internal/hooks/gitstatus.go @@ -76,3 +76,39 @@ func relPath(root, p string) string { } return p } + +// gitHead is HEAD's commit, or "" (unborn, not a repository). +func gitHead(ctx context.Context, root string) string { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "-q", "--verify", "HEAD") //nolint:gosec // G204: fixed read-only git subcommand + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "LC_ALL=C") + b, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// committedSince are the paths changed by the commits from..HEAD: files the +// turn committed, which git status no longer shows. +func committedSince(ctx context.Context, root, from string) []string { + if from == "" { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", root, "diff", "--name-only", "-z", "--no-renames", from, "HEAD") //nolint:gosec // G204: fixed read-only git subcommand + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "LC_ALL=C") + b, err := cmd.Output() + if err != nil { + return nil + } + var out []string + for _, p := range strings.Split(string(b), "\x00") { + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index 7ec4ebe..3487338 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + gitlink "github.com/tryatlas/atlas-cli/internal/capture/git" "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/capture/store" ) @@ -46,9 +47,10 @@ func (fakeAgent) Parse(verb string, payload []byte) (*Event, error) { var p struct { Session, Transcript, Prompt string Files []string + Tool *record.Tool } _ = json.Unmarshal(payload, &p) - ev := &Event{SessionID: p.Session, Transcript: p.Transcript, Prompt: p.Prompt, Files: p.Files} + ev := &Event{SessionID: p.Session, Transcript: p.Transcript, Prompt: p.Prompt, Files: p.Files, Tool: p.Tool} switch verb { case "start": ev.Kind = SessionStart @@ -213,7 +215,7 @@ func TestDispatcherRecordsATurnDirectly(t *testing.T) { st := enabledStore(t, root) transcript := filepath.Join(t.TempDir(), "s.jsonl") _ = os.WriteFile(transcript, []byte(`{"kind":"prompt","body":"earlier turn"}`+"\n"), 0o600) - d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test"), Banner: "Atlas: on"} + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil), Banner: "Atlas: on"} sess := map[string]any{"Session": "s-1", "Transcript": transcript} if out := runHook(t, d, "start", sess); string(out) != "Atlas: on" { @@ -291,12 +293,74 @@ func TestDispatcherRecordsATurnDirectly(t *testing.T) { } } +func TestPerEventDeliveryOpensAndClosesOneTurn(t *testing.T) { + root := gitRepo(t) + st := enabledStore(t, root) + transcript := filepath.Join(t.TempDir(), "s.jsonl") + _ = os.WriteFile(transcript, nil, 0o600) + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)} + sess := map[string]any{"Session": "s-4", "Transcript": transcript} + runHook(t, d, "start", sess) + runHook(t, d, "prompt", map[string]any{"Session": "s-4", "Transcript": transcript, "Prompt": "make a file"}) + db := st.DB() + var n int + _ = db.QueryRow(`SELECT count(*) FROM turn WHERE state='open'`).Scan(&n) + if n != 1 { + t.Fatalf("open turns after the prompt = %d, want 1", n) + } + _ = db.QueryRow(`SELECT count(*) FROM agent_message WHERE role='user'`).Scan(&n) + if n != 1 { + t.Fatalf("prompt rows = %d", n) + } + // The post-tool hook reports the write as it completes. + _ = os.WriteFile(filepath.Join(root, "made.txt"), []byte("x\n"), 0o644) + args, _ := json.Marshal(map[string]string{"file_path": filepath.Join(root, "made.txt"), "content": "x\n"}) + tool := &record.Tool{NativeID: "tc", Name: "Write", Arguments: args, Status: "completed"} + runHook(t, d, "tool", map[string]any{"Session": "s-4", "Transcript": transcript, "Tool": tool}) + var status string + if err := db.QueryRow(`SELECT status FROM tool_call WHERE native_call_id='tc'`).Scan(&status); err != nil || status != "completed" { + t.Fatalf("tool after the hook: %q %v", status, err) + } + _ = db.QueryRow(`SELECT count(*) FROM file_touch WHERE path='made.txt'`).Scan(&n) + if n != 1 { + t.Fatalf("touches after the hook = %d", n) + } + // The transcript names the same call again, plus the reply; the close + // must not duplicate the call. + f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600) + _, _ = f.WriteString(`{"kind":"prompt","body":"make a file"}` + "\n" + + `{"kind":"tool","path":"` + filepath.Join(root, "made.txt") + `","body":"x\n"}` + "\n" + + `{"kind":"text","body":"Made it."}` + "\n") + _ = f.Close() + runHook(t, d, "stop", sess) + _ = db.QueryRow(`SELECT count(*) FROM turn`).Scan(&n) + if n != 1 { + t.Fatalf("turns = %d, want 1", n) + } + _ = db.QueryRow(`SELECT count(*) FROM turn WHERE state='completed'`).Scan(&n) + if n != 1 { + t.Fatal("the turn must be completed") + } + _ = db.QueryRow(`SELECT count(*) FROM tool_call`).Scan(&n) + if n != 1 { + t.Fatalf("tool calls = %d, want 1 (no duplicate from the transcript)", n) + } + _ = db.QueryRow(`SELECT count(*) FROM file_touch`).Scan(&n) + if n != 1 { + t.Fatalf("touches = %d, want 1", n) + } + _ = db.QueryRow(`SELECT count(*) FROM agent_message`).Scan(&n) + if n != 2 { + t.Fatalf("messages = %d, want the prompt and the reply", n) + } +} + func TestATurnThatOnlyReadRecordsNothing(t *testing.T) { root := gitRepo(t) st := enabledStore(t, root) transcript := filepath.Join(t.TempDir(), "s.jsonl") _ = os.WriteFile(transcript, nil, 0o600) - d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test")} + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)} sess := map[string]any{"Session": "s-2", "Transcript": transcript} runHook(t, d, "start", sess) runHook(t, d, "prompt", sess) @@ -317,7 +381,7 @@ func TestSpoolWhenTheStoreIsLockedAndDrainLater(t *testing.T) { } transcript := filepath.Join(t.TempDir(), "s.jsonl") _ = os.WriteFile(transcript, []byte(`{"kind":"prompt","body":"hi"}`+"\n"+`{"kind":"text","body":"hello"}`+"\n"), 0o600) - d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test")} + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)} sess := map[string]any{"Session": "s-3", "Transcript": transcript} runHook(t, d, "start", sess) runHook(t, d, "prompt", map[string]any{"Session": "s-3", "Transcript": transcript, "Prompt": "hi"}) @@ -328,11 +392,11 @@ func TestSpoolWhenTheStoreIsLockedAndDrainLater(t *testing.T) { _ = stt.Save(root) runHook(t, d, "stop", sess) stt, _ = LoadState(root, "fake", "s-3") - if stt.Pending != 2 { // the session start and the turn - t.Fatalf("pending = %d, want 2 (spooled)", stt.Pending) + if stt.Pending != 3 { // the session start, the opening prompt and the turn + t.Fatalf("pending = %d, want 3 (spooled)", stt.Pending) } ents, _ := os.ReadDir(SpoolDir(root)) - if len(ents) != 2 { // the start and the turn + if len(ents) != 3 { // the start, the prompt and the turn t.Fatalf("spool entries = %d", len(ents)) } _ = lock.Release() @@ -409,3 +473,152 @@ func TestGitHooksInstallChainsAndRestores(t *testing.T) { t.Fatalf("restored = %q", b) } } + +func tool(id, name, args string) record.CapturedItem { + return record.CapturedItem{Tool: &record.Tool{NativeID: id, Name: name, Arguments: json.RawMessage(args), Status: "completed"}} +} + +func TestDropDeliveredPrefersIDsAndKeepsDistinctCommands(t *testing.T) { + // Ids match between hook and transcript: two distinct `sleep 4` calls, + // one delivered, one whose hook failed; the second must survive. + delivered := [][]string{toolKeys(*tool("c1", "Bash", `{"command":"sleep 4"}`).Tool)} + items := []record.CapturedItem{tool("c1", "Bash", `{"command":"sleep 4"}`), tool("c2", "Bash", `{"command":"sleep 4"}`), {ID: "m", Mode: "text", Body: "ok"}} + kept := dropDelivered(items, delivered) + if len(kept) != 3 || !kept[0].Tool.NoSample || kept[1].Tool == nil || kept[1].Tool.NativeID != "c2" || kept[1].Tool.NoSample || kept[2].Tool != nil { + t.Fatalf("kept = %+v", kept) + } + // Ids differ (another agent): match by arguments even when the transcript + // pretty-prints them, each delivered command once. + delivered = [][]string{toolKeys(*tool("h1", "bash", `{"command":"ls"}`).Tool), toolKeys(*tool("h2", "write", `{"path":"a.txt","contents":"x"}`).Tool)} + items = []record.CapturedItem{tool("t1", "Shell", `{ "command": "ls" }`), tool("t2", "Shell", `{"command":"ls"}`), tool("t3", "Write", `{"path": "a.txt", "contents": "x"}`)} + kept = dropDelivered(items, delivered) + if len(kept) != 1 || kept[0].Tool.NativeID != "t2" { + t.Fatalf("kept = %+v", kept) + } + // Empty arguments never match by arguments; an argv command matches a + // string one. + delivered = [][]string{toolKeys(*tool("h3", "mcp__x__briefing", `{}`).Tool), toolKeys(*tool("h4", "shell", `{"command":["bash","-lc","cat a"]}`).Tool)} + items = []record.CapturedItem{tool("t4", "mcp__y__other", `{}`), tool("t5", "Bash", `{"command":"bash -lc cat a"}`)} + kept = dropDelivered(items, delivered) + if len(kept) != 1 || kept[0].Tool.NativeID != "t4" { + t.Fatalf("kept = %+v", kept) + } +} + +func TestShellChangesLandOnTheShellCall(t *testing.T) { + root := gitRepo(t) + st := enabledStore(t, root) + transcript := filepath.Join(t.TempDir(), "s.jsonl") + _ = os.WriteFile(transcript, nil, 0o600) + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)} + sess := map[string]any{"Session": "s-5", "Transcript": transcript} + runHook(t, d, "start", sess) + runHook(t, d, "prompt", map[string]any{"Session": "s-5", "Transcript": transcript, "Prompt": "touch a file with the shell"}) + _ = os.WriteFile(filepath.Join(root, "by-shell.txt"), []byte("x\n"), 0o644) + args, _ := json.Marshal(map[string]string{"command": "echo x > by-shell.txt"}) + runHook(t, d, "tool", map[string]any{"Session": "s-5", "Transcript": transcript, + "Tool": &record.Tool{NativeID: "sh1", Name: "Bash", Arguments: args, Status: "completed"}}) + db := st.DB() + var path, callID string + if err := db.QueryRow(`SELECT f.path, c.native_call_id FROM file_touch f JOIN tool_call c ON c.id = f.tool_call_id`).Scan(&path, &callID); err != nil || path != "by-shell.txt" || callID != "sh1" { + t.Fatalf("touch after the shell hook: %q %q %v", path, callID, err) + } + f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600) + _, _ = f.WriteString(`{"kind":"text","body":"Done."}` + "\n") + _ = f.Close() + runHook(t, d, "stop", sess) + var n int + _ = db.QueryRow(`SELECT count(*) FROM tool_call`).Scan(&n) + if n != 1 { + t.Fatalf("tool calls = %d, want only the shell call (no synthetic changes call)", n) + } + _ = db.QueryRow(`SELECT count(*) FROM file_touch`).Scan(&n) + if n != 1 { + t.Fatalf("touches = %d, want 1", n) + } +} + +func gitIn(t *testing.T, dir string, args ...string) { + t.Helper() + c := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=T", "-c", "user.email=t@e", "-c", "commit.gpgsign=false"}, args...)...) + c.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null") + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } +} + +func TestACommitMadeDuringTheTurnGetsItsCheckpoint(t *testing.T) { + for _, perEvent := range []bool{true, false} { + root := gitRepo(t) + st := enabledStore(t, root) + transcript := filepath.Join(t.TempDir(), "s.jsonl") + _ = os.WriteFile(transcript, nil, 0o600) + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)} + sess := map[string]any{"Session": "s-c", "Transcript": transcript} + runHook(t, d, "start", sess) + runHook(t, d, "prompt", map[string]any{"Session": "s-c", "Transcript": transcript, "Prompt": "write and commit"}) + // One shell command writes the file and commits it; the + // post-commit hook would run here and find no touch yet. + _ = os.WriteFile(filepath.Join(root, "c2.txt"), []byte("two\n"), 0o644) + gitIn(t, root, "add", "c2.txt") + gitIn(t, root, "commit", "-q", "-m", "agent commit") + if perEvent { + args, _ := json.Marshal(map[string]string{"command": "echo two > c2.txt && git add c2.txt && git commit -m 'agent commit'"}) + runHook(t, d, "tool", map[string]any{"Session": "s-c", "Transcript": transcript, + "Tool": &record.Tool{NativeID: "sh", Name: "Bash", Arguments: args, Status: "completed"}}) + } + f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600) + _, _ = f.WriteString(`{"kind":"text","body":"Committed."}` + "\n") + _ = f.Close() + runHook(t, d, "stop", sess) + var files string + if err := st.DB().QueryRow(`SELECT files_touched FROM checkpoint`).Scan(&files); err != nil || !strings.Contains(files, "c2.txt") { + t.Fatalf("perEvent=%v: checkpoint files = %q %v", perEvent, files, err) + } + var live int + _ = st.DB().QueryRow(`SELECT count(*) FROM file_touch WHERE path = 'c2.txt' AND consumed_by_commit IS NULL`).Scan(&live) + if live != 0 { + t.Fatalf("perEvent=%v: the late touch must be consumed by the commit it linked to", perEvent) + } + } +} + +func TestCommittingAFileWrittenBeforeTheTurnGetsItsCheckpoint(t *testing.T) { + root := gitRepo(t) + st := enabledStore(t, root) + transcript := filepath.Join(t.TempDir(), "s.jsonl") + _ = os.WriteFile(transcript, nil, 0o600) + // greet.js was written earlier (another session, or by hand) and is + // untracked when the turn starts; the agent only stages and commits it. + _ = os.WriteFile(filepath.Join(root, "greet.js"), []byte("hi\n"), 0o644) + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)} + sess := map[string]any{"Session": "s-g", "Transcript": transcript} + runHook(t, d, "start", sess) + runHook(t, d, "prompt", map[string]any{"Session": "s-g", "Transcript": transcript, "Prompt": "commit greet.js"}) + shell := func(id, cmd string) { + args, _ := json.Marshal(map[string]string{"command": cmd}) + runHook(t, d, "tool", map[string]any{"Session": "s-g", "Transcript": transcript, + "Tool": &record.Tool{NativeID: id, Name: "Bash", Arguments: args, Status: "completed"}}) + } + // The agent reads the file first: a read explains no change. + readArgs, _ := json.Marshal(map[string]string{"filePath": filepath.Join(root, "greet.js")}) + runHook(t, d, "tool", map[string]any{"Session": "s-g", "Transcript": transcript, + "Tool": &record.Tool{NativeID: "r", Name: "read", Arguments: readArgs, Locations: []string{filepath.Join(root, "greet.js")}, Status: "completed"}}) + gitIn(t, root, "add", "greet.js") + shell("a", "git add greet.js") + gitIn(t, root, "commit", "-q", "-m", "feat: greet") + // git's post-commit hook walks the commit before the agent's tool hook + // reports the command. + if _, err := gitlink.Link(context.Background(), st, root, time.Now); err != nil { + t.Fatal(err) + } + shell("c", `git commit -m "feat: greet"`) + f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600) + _, _ = f.WriteString(`{"kind":"text","body":"Committed."}` + "\n") + _ = f.Close() + runHook(t, d, "stop", sess) + var files string + if err := st.DB().QueryRow(`SELECT files_touched FROM checkpoint`).Scan(&files); err != nil || !strings.Contains(files, "greet.js") { + t.Fatalf("checkpoint files = %q %v", files, err) + } +} diff --git a/internal/hooks/mcpconfig/state_main_test.go b/internal/hooks/mcpconfig/state_main_test.go new file mode 100644 index 0000000..1a56c3f --- /dev/null +++ b/internal/hooks/mcpconfig/state_main_test.go @@ -0,0 +1,15 @@ +package mcpconfig + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/opencode/export.go b/internal/hooks/opencode/export.go index 2097482..21ed4aa 100644 --- a/internal/hooks/opencode/export.go +++ b/internal/hooks/opencode/export.go @@ -292,10 +292,13 @@ func turnOf(msgs []message) *hooks.Turn { return t } -// toolOf translates a tool part. +// toolOf translates a tool part. Its id is the callID, which the plugin's +// tool.execute.after hook also receives (opencode.go toolOf): one id for one +// call, however it arrives. The part id (prt_…) stands in when a part has no +// callID. func toolOf(p part, fallbackID string) *record.Tool { tool := &record.Tool{NativeID: p.CallID, Name: p.Tool, Status: "completed"} - if p.ID != "" { + if tool.NativeID == "" { tool.NativeID = p.ID } if tool.NativeID == "" { diff --git a/internal/hooks/opencode/export_test.go b/internal/hooks/opencode/export_test.go index 1f37a4c..1c7507e 100644 --- a/internal/hooks/opencode/export_test.go +++ b/internal/hooks/opencode/export_test.go @@ -75,8 +75,8 @@ func TestTurnOf(t *testing.T) { if write == nil || write.Name != "write" || write.Locations != nil || !strings.Contains(string(write.Arguments), `"filePath": "/x/hello.txt"`) { t.Fatalf("write %+v", write) } - patch := turn.Items[3].Tool - if patch == nil || patch.NativeID != "prt_ap" || strings.Join(patch.Locations, ",") != "/x/a.go,/x/b.go" { + patch := turn.Items[3].Tool // the callID wins over the part id: it is what the tool hook sends too + if patch == nil || patch.NativeID != "call_p" || strings.Join(patch.Locations, ",") != "/x/a.go,/x/b.go" { t.Fatalf("apply_patch %+v", patch) } bash := turn.Items[4].Tool diff --git a/internal/hooks/opencode/opencode.go b/internal/hooks/opencode/opencode.go index d34b1a4..422a8f9 100644 --- a/internal/hooks/opencode/opencode.go +++ b/internal/hooks/opencode/opencode.go @@ -16,6 +16,7 @@ import ( "path/filepath" "time" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" "github.com/tryatlas/atlas-cli/internal/project" ) @@ -35,6 +36,7 @@ const ( VerbTurnEnd = "turn-end" VerbCompaction = "compaction" VerbSessionEnd = "session-end" + VerbToolUse = "tool-use" ) // Plugin is the plugin OpenCode loads from .opencode/plugins/atlas.ts. @@ -57,7 +59,7 @@ func (Agent) Binary() string { return "opencode" } // Verbs are the hook verbs the plugin invokes. func (Agent) Verbs() []string { - return []string{VerbSessionStart, VerbTurnStart, VerbTurnEnd, VerbCompaction, VerbSessionEnd} + return []string{VerbSessionStart, VerbTurnStart, VerbToolUse, VerbTurnEnd, VerbCompaction, VerbSessionEnd} } // PluginPath is the plugin file under root. OpenCode loads every file in @@ -133,11 +135,39 @@ func (Agent) Response(hooks.Kind, string) []byte { return nil } func (Agent) ResumeCommand(id string) string { return "opencode -s " + id } // payload is what the plugin sends: session_id always, prompt and model at -// turn start, model at turn end. +// turn start, model at turn end, and at tool-use the completed call from +// OpenCode's tool.execute.after hook: the tool's wire name, its call id (the +// callID the export's tool part carries too), the title and output OpenCode +// shows, the tool's metadata, and the arguments (from the hook's input, or +// remembered from tool.execute.before). type payload struct { - SessionID string `json:"session_id"` - Prompt string `json:"prompt"` - Model string `json:"model"` + SessionID string `json:"session_id"` + Prompt string `json:"prompt"` + Model string `json:"model"` + Tool string `json:"tool"` + CallID string `json:"call_id"` + Title string `json:"title"` + Output *string `json:"output"` + Metadata json.RawMessage `json:"metadata"` + Args json.RawMessage `json:"args"` +} + +// toolOf is the completed call a tool-use payload describes, in the shape +// the export reader gives the same call (export.go toolOf): the same +// NativeID, so the turn-end read drops it as already delivered. +func (p payload) toolOf() *record.Tool { + if p.CallID == "" || p.Tool == "" { + return nil + } + t := &record.Tool{NativeID: p.CallID, Name: p.Tool, Title: p.Title, Status: "completed"} + if a := bytes.TrimSpace(p.Args); len(a) > 0 && string(a) != "null" { + t.Arguments = a + } + if p.Output != nil { + t.Result = p.Output + } + t.Locations = (&toolState{Metadata: p.Metadata}).files() + return t } // Parse maps a hook verb and payload to an event. The payload names no @@ -158,6 +188,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { case VerbTurnStart: ev.Kind = hooks.TurnStart ev.Prompt = StripReminders(p.Prompt) + case VerbToolUse: + ev.Kind = hooks.ToolUse + ev.Tool = p.toolOf() + if ev.Tool == nil { + return nil, nil + } case VerbTurnEnd: ev.Kind = hooks.TurnEnd case VerbCompaction: diff --git a/internal/hooks/opencode/opencode_test.go b/internal/hooks/opencode/opencode_test.go index c07329c..1847e70 100644 --- a/internal/hooks/opencode/opencode_test.go +++ b/internal/hooks/opencode/opencode_test.go @@ -32,6 +32,11 @@ func TestPluginInstallStateUninstall(t *testing.T) { t.Fatalf("plugin does not send %s", verb) } } + for _, hook := range []string{`"tool.execute.before"`, `"tool.execute.after"`, `sendSync("tool-use"`} { + if !strings.Contains(string(b), hook) { + t.Fatalf("plugin lacks %s", hook) + } + } if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o644 { t.Fatalf("mode %v", fi.Mode().Perm()) } @@ -124,13 +129,40 @@ func TestParse(t *testing.T) { t.Fatalf("%s: transcript %q", c.verb, ev.Transcript) } } + // tool-use: OpenCode's tool.execute.after as the plugin forwards it, with + // the id the export's tool part carries as callID. + toolUse := `{"session_id":"` + id + `","tool":"write","call_id":"ZdNFvSAofdecladc","title":"rt.txt","output":"Wrote file successfully.",` + + `"metadata":{"diagnostics":{},"filepath":"/w/rt.txt","exists":false},"args":{"filePath":"/w/rt.txt","content":"realtime\n"}}` + ev, err := a.Parse(VerbToolUse, []byte(toolUse)) + if err != nil || ev == nil || ev.Kind != hooks.ToolUse || ev.SessionID != id || ev.Tool == nil { + t.Fatalf("tool-use: %+v %v", ev, err) + } + if tool := ev.Tool; tool.NativeID != "ZdNFvSAofdecladc" || tool.Name != "write" || tool.Title != "rt.txt" || tool.Status != "completed" || + string(tool.Arguments) != `{"filePath":"/w/rt.txt","content":"realtime\n"}` || tool.Result == nil || *tool.Result != "Wrote file successfully." || tool.Locations != nil { + t.Fatalf("tool-use tool: %+v", tool) + } + if !strings.HasSuffix(ev.Transcript, filepath.Join("hooks", ID, "export", id+".json")) { + t.Fatalf("tool-use names no export cache: %q", ev.Transcript) + } + // apply_patch's metadata lists the files it wrote; null args and a + // missing output are nothing, not "null". + ev, err = a.Parse(VerbToolUse, []byte(`{"session_id":"`+id+`","tool":"apply_patch","call_id":"call_p","metadata":{"files":[{"filePath":"/w/a.go"},{"filePath":"/w/b.go"}]},"args":null}`)) + if err != nil || ev == nil || ev.Tool == nil || strings.Join(ev.Tool.Locations, ",") != "/w/a.go,/w/b.go" || ev.Tool.Arguments != nil || ev.Tool.Result != nil { + t.Fatalf("apply_patch tool-use: %+v %v", ev.Tool, err) + } + // Without a call id or a tool name there is no call to record. + for _, raw := range []string{`{"session_id":"` + id + `","tool":"bash"}`, `{"session_id":"` + id + `","call_id":"c1"}`} { + if ev, err := a.Parse(VerbToolUse, []byte(raw)); ev != nil || err != nil { + t.Fatalf("tool-use without a call: %+v %v", ev, err) + } + } if ev, err := a.Parse("something-else", []byte(`{"session_id":"x"}`)); ev != nil || err != nil { t.Fatalf("unknown verb: %v %v", ev, err) } if _, err := a.Parse(VerbTurnEnd, []byte(`not json`)); err == nil { t.Fatal("bad payload accepted") } - ev, err := a.Parse(VerbTurnEnd, []byte(`{"session_id":"../../etc"}`)) + ev, err = a.Parse(VerbTurnEnd, []byte(`{"session_id":"../../etc"}`)) if err != nil || ev.Transcript != "" { t.Fatalf("an unusable id named a cache: %+v %v", ev, err) } diff --git a/internal/hooks/opencode/plugin.ts b/internal/hooks/opencode/plugin.ts index fe24ab0..ee5a812 100644 --- a/internal/hooks/opencode/plugin.ts +++ b/internal/hooks/opencode/plugin.ts @@ -1,7 +1,7 @@ // Atlas plugin for OpenCode — written by atlas agents add; do not edit (rewritten on the next add). // -// Forwards OpenCode's session lifecycle to `atlas hooks opencode ` with -// one JSON payload on stdin. node:child_process runs under Bun (the CLI and +// Forwards OpenCode's session lifecycle, and each completed tool call, to +// `atlas hooks opencode ` with one JSON payload on stdin. node:child_process runs under Bun (the CLI and // TUI) and Node (the desktop sidecar). A machine without atlas on PATH runs a // silent no-op, and nothing here throws: a failing hook never fails OpenCode. import { spawn, spawnSync } from "node:child_process" @@ -16,6 +16,8 @@ export const AtlasPlugin: Plugin = async ({ directory }) => { const roles = new Map() // message id → role const early = new Map() // user text seen before its message.updated const started = new Set() // user message ids whose turn-start fired + const toolArgs = new Map() // callID → args, from tool.execute.before + const MAX_OUTPUT = 64 * 1024 // bytes of tool output (and of metadata) a payload carries function argv(verb: string): string[] { return ["sh", "-c", `if ! command -v atlas >/dev/null 2>&1; then exit 0; fi; exec atlas hooks opencode ${verb}`] @@ -54,6 +56,29 @@ export const AtlasPlugin: Plugin = async ({ directory }) => { roles.clear() early.clear() started.clear() + toolArgs.clear() + } + + // clip bounds a string to MAX_OUTPUT UTF-16 units (at most 2× the bytes), + // marking the cut. + function clip(text: unknown): string { + const s = typeof text === "string" ? text : text === undefined || text === null ? "" : String(text) + if (s.length <= MAX_OUTPUT) return s + return s.slice(0, MAX_OUTPUT) + `\n… [truncated ${s.length - MAX_OUTPUT} chars]` + } + + // smallMetadata keeps the tool's metadata when it is small; a large one + // (read's display text, a big diff) is cut down to the files it names, + // which is what the hook reads from it. + function smallMetadata(meta: unknown): unknown { + if (meta === undefined || meta === null || typeof meta !== "object") return meta ?? null + try { + if (JSON.stringify(meta).length <= MAX_OUTPUT) return meta + const files = (meta as any).files + return Array.isArray(files) ? { files, truncated: true } : { truncated: true } + } catch { + return null + } } // follow makes id the tracked session; it reports whether that is new. @@ -73,6 +98,44 @@ export const AtlasPlugin: Plugin = async ({ directory }) => { } return { + // The arguments as the tool is about to run; the after hook carries them + // too in recent OpenCode, and this copy covers versions whose after + // hook does not. + "tool.execute.before": async (input, output) => { + try { + if (!input?.callID) return + if (toolArgs.size > 256) toolArgs.clear() + toolArgs.set(input.callID, output?.args) + } catch { + // never fail OpenCode + } + }, + + // One completed tool call, delivered into the open turn now (before the + // reply closes it); the turn-end export then skips it by callID. Waited + // for, so a short task's rows land before the same tick's idle event + // runs turn-end. + "tool.execute.after": async (input, output) => { + try { + if (!input?.callID || !input.tool) return + const sessionID = input.sessionID ?? session + const args = (input as any).args !== undefined ? (input as any).args : toolArgs.get(input.callID) + toolArgs.delete(input.callID) + if (!sessionID || children.has(sessionID)) return + sendSync("tool-use", { + session_id: sessionID, + tool: input.tool, + call_id: input.callID, + title: typeof output?.title === "string" ? output.title : "", + output: clip(output?.output), + metadata: smallMetadata(output?.metadata), + args: args === undefined ? null : args, + }) + } catch { + // never fail OpenCode + } + }, + event: async ({ event }) => { try { const props = (event as any).properties diff --git a/internal/hooks/opencode/state_main_test.go b/internal/hooks/opencode/state_main_test.go new file mode 100644 index 0000000..4e7c1ef --- /dev/null +++ b/internal/hooks/opencode/state_main_test.go @@ -0,0 +1,15 @@ +package opencode + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/pi/extension.ts b/internal/hooks/pi/extension.ts index 50b2177..f38a6fe 100644 --- a/internal/hooks/pi/extension.ts +++ b/internal/hooks/pi/extension.ts @@ -9,6 +9,8 @@ import { execFile } from "node:child_process"; const HOOK_TIMEOUT_MS = 10_000; const MAX_OUTPUT = 4 * 1024 * 1024; +// A tool result is forwarded as text, cut here; the session file keeps it whole. +const MAX_RESULT = 64 * 1024; // A nested pi (a subagent spawns one per task, inside the project, where Pi // finds this same extension) must not report its lifecycle as the user's @@ -20,7 +22,7 @@ function hookCommand(verb: string): string { return `if ! command -v atlas >/dev/null 2>&1; then exit 0; fi; exec atlas hooks pi ${verb}`; } -function fire(verb: string, payload: Record): Promise { +function run(verb: string, payload: Record): Promise { return new Promise((resolve) => { try { const child = execFile( @@ -37,6 +39,33 @@ function fire(verb: string, payload: Record): Promise { }); } +// Hooks run one at a time, in the order they were fired. Pi executes +// parallel tool calls concurrently and fires their tool_result events +// together; concurrent hook processes would race on the session's state +// file and the store's writer lock, and a tool result could land after the +// agent_end that closes its turn. +let queue: Promise = Promise.resolve(); + +function fire(verb: string, payload: Record): Promise { + const next = queue.then(() => run(verb, payload)); + queue = next.catch(() => {}); + return next; +} + +// resultText flattens a tool result's content to its text parts, truncated. +function resultText(content: unknown): string { + if (typeof content === "string") return content.slice(0, MAX_RESULT); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if (c && typeof c === "object" && (c as { type?: unknown }).type === "text") { + const text = (c as { text?: unknown }).text; + if (typeof text === "string" && text !== "") parts.push(text); + } + } + return parts.join("\n").slice(0, MAX_RESULT); +} + export default function (pi: ExtensionAPI) { const nested = Boolean(process.env[NESTED_ENV]); process.env[NESTED_ENV] = "1"; @@ -59,6 +88,25 @@ export default function (pi: ExtensionAPI) { }); }); + // Each completed tool call is delivered as it finishes, so the capture + // holds the turn's calls before the reply lands. The call id is the one + // the session file's toolCall carries, which is how the turn-end read + // knows not to record the call twice. A nested call (one tool running + // another) is not in the transcript and is left to its parent. + pi.on("tool_result", async (event, ctx) => { + if (event.parentToolCallId) return; + await fire("tool-result", { + type: "tool_result", + cwd: ctx.cwd, + session_file: ctx.sessionManager.getSessionFile(), + tool_call_id: event.toolCallId, + tool_name: event.toolName, + args: event.input ?? {}, + result: resultText(event.content), + is_error: Boolean(event.isError), + }); + }); + pi.on("agent_end", async (_event, ctx) => { await fire("agent-end", { type: "agent_end", diff --git a/internal/hooks/pi/pi.go b/internal/hooks/pi/pi.go index f5c4a29..3d6a726 100644 --- a/internal/hooks/pi/pi.go +++ b/internal/hooks/pi/pi.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" + "github.com/tryatlas/atlas-cli/internal/capture/record" "github.com/tryatlas/atlas-cli/internal/hooks" ) @@ -29,6 +30,7 @@ type Agent struct{} const ( VerbSessionStart = "session-start" VerbBeforeAgentStart = "before-agent-start" + VerbToolResult = "tool-result" VerbAgentEnd = "agent-end" ) @@ -50,7 +52,7 @@ func (Agent) Binary() string { return "pi" } // Verbs are the hook verbs the extension invokes. func (Agent) Verbs() []string { - return []string{VerbSessionStart, VerbBeforeAgentStart, VerbAgentEnd} + return []string{VerbSessionStart, VerbBeforeAgentStart, VerbToolResult, VerbAgentEnd} } // ExtensionDir is the directory Pi loads the Atlas extension from; Pi @@ -145,6 +147,25 @@ type payload struct { SessionFile string `json:"session_file"` SessionID string `json:"session_id"` Prompt string `json:"prompt"` + // tool-result: the completed call, with its result as text. + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Args json.RawMessage `json:"args"` + Result *string `json:"result"` + IsError bool `json:"is_error"` +} + +// toolOf is the completed call a tool-result payload describes; nil when +// the payload names no call. +func (p payload) toolOf() *record.Tool { + if p.ToolCallID == "" || p.ToolName == "" { + return nil + } + t := &record.Tool{NativeID: p.ToolCallID, Name: p.ToolName, Arguments: p.Args, Result: p.Result, Status: "completed"} + if p.IsError { + t.Status = "failed" + } + return t } // Parse maps a hook verb and payload to an event. A payload naming no @@ -170,6 +191,12 @@ func (Agent) Parse(verb string, raw []byte) (*hooks.Event, error) { case VerbBeforeAgentStart: ev.Kind = hooks.TurnStart ev.Prompt = p.Prompt + case VerbToolResult: + ev.Kind = hooks.ToolUse + ev.Tool = p.toolOf() + if ev.Tool == nil { + return nil, nil + } case VerbAgentEnd: ev.Kind = hooks.TurnEnd default: diff --git a/internal/hooks/pi/pi_test.go b/internal/hooks/pi/pi_test.go index 8ca152d..9a74ae9 100644 --- a/internal/hooks/pi/pi_test.go +++ b/internal/hooks/pi/pi_test.go @@ -26,7 +26,7 @@ func TestInstallStateUninstall(t *testing.T) { if !strings.HasPrefix(string(b), "// Atlas extension for Pi — written by atlas agents add; do not edit (rewritten on the next add).\n") { t.Fatalf("header missing:\n%s", b[:120]) } - for _, want := range []string{"exec atlas hooks pi ${verb}", `"session-start"`, `"before-agent-start"`, `"agent-end"`, "ATLAS_PI_NESTED", "command -v atlas", "10_000"} { + for _, want := range []string{"exec atlas hooks pi ${verb}", `"session-start"`, `"before-agent-start"`, `"tool-result"`, `"agent-end"`, `pi.on("tool_result"`, "parentToolCallId", "64 * 1024", "let queue", "ATLAS_PI_NESTED", "command -v atlas", "10_000"} { if !strings.Contains(string(b), want) { t.Errorf("extension lacks %q", want) } @@ -124,6 +124,30 @@ func TestParse(t *testing.T) { if ev.Kind != hooks.TurnEnd || ev.SessionID != "explicit" { t.Fatalf("agent-end = %+v", ev) } + ev, err = a.Parse(VerbToolResult, []byte(`{"type":"tool_result","cwd":"/repo","session_file":"`+file+`","tool_call_id":"call_1","tool_name":"write","args":{"path":"rt.txt","content":"realtime\n"},"result":"Successfully wrote 9 bytes to rt.txt","is_error":false}`)) + if err != nil { + t.Fatal(err) + } + if ev.Kind != hooks.ToolUse || ev.Tool == nil || ev.SessionID != "3f2b1c4d-aaaa-bbbb-cccc-0123456789ab" { + t.Fatalf("tool-result = %+v", ev) + } + if ev.Tool.NativeID != "call_1" || ev.Tool.Name != "write" || ev.Tool.Status != "completed" || string(ev.Tool.Arguments) != `{"path":"rt.txt","content":"realtime\n"}` { + t.Fatalf("tool = %+v", ev.Tool) + } + if ev.Tool.Result == nil || *ev.Tool.Result != "Successfully wrote 9 bytes to rt.txt" { + t.Fatalf("tool result = %v", ev.Tool.Result) + } + ev, err = a.Parse(VerbToolResult, []byte(`{"type":"tool_result","session_file":"`+file+`","tool_call_id":"call_2","tool_name":"bash","args":{"command":"cat missing"},"result":"cat: missing: No such file","is_error":true}`)) + if err != nil { + t.Fatal(err) + } + if ev.Tool == nil || ev.Tool.Status != "failed" || ev.Tool.NativeID != "call_2" { + t.Fatalf("failed tool-result = %+v", ev.Tool) + } + // A result payload with no call to speak of is dropped. + if ev, err := a.Parse(VerbToolResult, []byte(`{"type":"tool_result","session_file":"`+file+`","tool_name":"bash"}`)); err != nil || ev != nil { + t.Fatalf("callless tool-result = %+v, %v", ev, err) + } // No session: nothing to track (pi --no-session). if ev, err := a.Parse(VerbAgentEnd, []byte(`{"type":"agent_end","cwd":"/repo"}`)); err != nil || ev != nil { t.Fatalf("sessionless = %+v, %v", ev, err) diff --git a/internal/hooks/pi/state_main_test.go b/internal/hooks/pi/state_main_test.go new file mode 100644 index 0000000..4b56897 --- /dev/null +++ b/internal/hooks/pi/state_main_test.go @@ -0,0 +1,15 @@ +package pi + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hooks/plans.go b/internal/hooks/plans.go new file mode 100644 index 0000000..56b56c1 --- /dev/null +++ b/internal/hooks/plans.go @@ -0,0 +1,271 @@ +package hooks + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/tryatlas/atlas-cli/internal/capture/record" + memrecord "github.com/tryatlas/atlas-cli/internal/memory/record" + "github.com/tryatlas/atlas-cli/internal/memory/server" +) + +// planMaxRunes caps a plan as the ACP session does. +const planMaxRunes = 600 + +// planOf reads a plan out of an agent's plan tool call: Claude Code's and +// Factory Droid's TodoWrite, Codex's update_plan, OpenCode's todowrite, +// Cursor's todo_write, Copilot's update_todo (a list, or a Markdown +// checklist), and Claude Code's ExitPlanMode (the plan as Markdown). The +// text is the ACP session's: "- [status] item" lines, at most 600 +// characters; status is done once every item is completed. +func planOf(t record.Tool) (text, status string, ok bool) { + name := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(t.Name)) + var args map[string]json.RawMessage + if json.Unmarshal(t.Arguments, &args) != nil { + return "", "", false + } + switch { + case name == "exitplanmode": + var md string + if json.Unmarshal(args["plan"], &md) != nil || strings.TrimSpace(md) == "" { + return "", "", false + } + return clipPlan(strings.TrimSpace(md)), "active", true + case strings.Contains(name, "todo") || name == "updateplan" || name == "plan": + default: + return "", "", false + } + for _, k := range []string{"todos", "plan", "items", "tasks"} { + raw, has := args[k] + if !has { + continue + } + var md string + if json.Unmarshal(raw, &md) == nil { + if strings.TrimSpace(md) == "" { + return "", "", false + } + return clipPlan(strings.TrimSpace(md)), checklistStatus(md), true + } + var items []map[string]any + if json.Unmarshal(raw, &items) != nil || len(items) == 0 { + continue + } + var lines []string + all := true + for _, it := range items { + content := firstString(it, "content", "step", "text", "title", "description", "task") + if content == "" { + continue + } + st := itemStatus(it) + if st != "completed" { + all = false + } + lines = append(lines, fmt.Sprintf("- [%s] %s", st, strings.Join(strings.Fields(content), " "))) + } + if len(lines) == 0 { + return "", "", false + } + status := "active" + if all { + status = "done" + } + return clipPlan(strings.Join(lines, "\n")), status, true + } + return "", "", false +} + +func firstString(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +// itemStatus is ACP's vocabulary: pending, in_progress, completed (and +// cancelled, kept as said). +func itemStatus(it map[string]any) string { + if b, ok := it["completed"].(bool); ok && b { + return "completed" + } + if b, ok := it["done"].(bool); ok && b { + return "completed" + } + s, _ := it["status"].(string) + if s == "" { + s, _ = it["state"].(string) + } + switch strings.ToLower(strings.ReplaceAll(s, "-", "_")) { + case "completed", "complete", "done", "finished": + return "completed" + case "in_progress", "inprogress", "active", "running", "doing": + return "in_progress" + case "cancelled", "canceled": + return "cancelled" + } + return "pending" +} + +// checklistStatus is done when a Markdown checklist has items and all are +// ticked. +func checklistStatus(md string) string { + open, done := 0, 0 + for _, l := range strings.Split(md, "\n") { + l = strings.TrimSpace(l) + switch { + case strings.HasPrefix(l, "- [ ]") || strings.HasPrefix(l, "* [ ]"): + open++ + case strings.HasPrefix(strings.ToLower(l), "- [x]") || strings.HasPrefix(strings.ToLower(l), "* [x]"): + done++ + } + } + if done > 0 && open == 0 { + return "done" + } + return "active" +} + +func clipPlan(s string) string { + if r := []rune(s); len(r) > planMaxRunes { + return string(r[:planMaxRunes]) + } + return s +} + +// foldPlans records the latest plan among items as the session's plan +// (plan_set: it supersedes the one before, as the app's working memory). +func foldPlans(ctx context.Context, mem *server.Server, g server.Grant, items []record.CapturedItem) { + var text, status string + for _, it := range items { + if it.Tool == nil { + continue + } + if t, s, ok := planOf(*it.Tool); ok { + text, status = t, s + } + } + if text != "" { + _ = mem.Fold(ctx, g, memrecord.Event{Kind: "plan_set", Text: text, Status: status}) + } +} + +// PlanTask is one task of an incremental task list. +type PlanTask struct { + ID string `json:"id"` + Subject string `json:"subject"` + Status string `json:"status"` +} + +// PlanFold is a plan to record. +type PlanFold struct { + Text string `json:"text"` + Status string `json:"status"` +} + +var taskNumber = regexp.MustCompile(`#(\d+)`) + +// trackTasks follows Claude Code's task tools: TaskCreate adds a task (its +// number is in the result, "Task #3 created"), TaskUpdate changes one +// (status, subject; "deleted" removes it). It returns the whole list as a +// plan when the call changed it. +func trackTasks(st *State, t record.Tool) *PlanFold { + name := strings.ToLower(t.Name) + if name != "taskcreate" && name != "taskupdate" { + return nil + } + if t.NativeID != "" { + if slices.Contains(st.TaskCalls, t.NativeID) { + return nil + } + st.TaskCalls = append(st.TaskCalls, t.NativeID) + } + var args map[string]any + if json.Unmarshal(t.Arguments, &args) != nil { + return nil + } + switch name { + case "taskcreate": + subject := firstString(args, "subject", "title", "content", "description") + if subject == "" { + return nil + } + id := "" + if t.Result != nil { + if m := taskNumber.FindStringSubmatch(*t.Result); m != nil { + id = m[1] + } + } + if id == "" { + id = fmt.Sprint(len(st.Tasks) + 1) + } + st.Tasks = append(st.Tasks, PlanTask{ID: id, Subject: strings.Join(strings.Fields(subject), " "), Status: "pending"}) + case "taskupdate": + id := fmt.Sprint(args["taskId"]) + if s, ok := args["taskId"].(string); ok { + id = s + } else if f, ok := args["taskId"].(float64); ok { + id = fmt.Sprint(int64(f)) + } + i := -1 + for j, task := range st.Tasks { + if task.ID == id { + i = j + } + } + if i < 0 { + return nil + } + if s, _ := args["status"].(string); s != "" { + if strings.EqualFold(s, "deleted") { + st.Tasks = append(st.Tasks[:i], st.Tasks[i+1:]...) + break + } + st.Tasks[i].Status = itemStatus(map[string]any{"status": s}) + } + if s := firstString(args, "subject"); s != "" { + st.Tasks[i].Subject = strings.Join(strings.Fields(s), " ") + } + default: + return nil + } + if len(st.Tasks) == 0 { + return nil + } + return planFromTasks(st.Tasks) +} + +// hadTaskCalls reports a task tool among items. +func hadTaskCalls(items []record.CapturedItem) bool { + for _, it := range items { + if it.Tool != nil { + if n := strings.ToLower(it.Tool.Name); n == "taskcreate" || n == "taskupdate" { + return true + } + } + } + return false +} + +// planFromTasks is the task list as plan text. +func planFromTasks(tasks []PlanTask) *PlanFold { + var lines []string + all := true + for _, task := range tasks { + lines = append(lines, fmt.Sprintf("- [%s] %s", task.Status, task.Subject)) + if task.Status != "completed" { + all = false + } + } + status := "active" + if all { + status = "done" + } + return &PlanFold{Text: clipPlan(strings.Join(lines, "\n")), Status: status} +} diff --git a/internal/hooks/plans_test.go b/internal/hooks/plans_test.go new file mode 100644 index 0000000..2526a4b --- /dev/null +++ b/internal/hooks/plans_test.go @@ -0,0 +1,99 @@ +package hooks + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/tryatlas/atlas-cli/internal/capture/record" + memrecord "github.com/tryatlas/atlas-cli/internal/memory/record" +) + +func TestPlanOfEachAgentsPlanTool(t *testing.T) { + for _, c := range []struct { + name, tool, args string + text, status string + }{ + {"claude todos", "TodoWrite", `{"todos":[{"content":"Read the code","status":"completed","activeForm":"Reading"},{"content":"Fix the bug","status":"in_progress"},{"content":"Run tests","status":"pending"}]}`, + "- [completed] Read the code\n- [in_progress] Fix the bug\n- [pending] Run tests", "active"}, + {"codex update_plan", "update_plan", `{"explanation":"x","plan":[{"step":"Write it","status":"completed"},{"step":"Ship it","status":"completed"}]}`, + "- [completed] Write it\n- [completed] Ship it", "done"}, + {"opencode todowrite", "todowrite", `{"todos":[{"id":"1","content":"Add greet","status":"in_progress","priority":"high"}]}`, + "- [in_progress] Add greet", "active"}, + {"cursor todo_write", "todo_write", `{"merge":false,"todos":[{"id":"a","content":"Lint","status":"cancelled"}]}`, + "- [cancelled] Lint", "active"}, + {"copilot checklist", "update_todo", `{"todos":"- [x] One\n- [x] Two"}`, "- [x] One\n- [x] Two", "done"}, + {"claude plan mode", "ExitPlanMode", `{"plan":"## Plan\n1. Do it"}`, "## Plan\n1. Do it", "active"}, + } { + text, status, ok := planOf(record.Tool{Name: c.tool, Arguments: []byte(c.args)}) + if !ok || text != c.text || status != c.status { + t.Errorf("%s: %v %q %q", c.name, ok, text, status) + } + } + if _, _, ok := planOf(record.Tool{Name: "Bash", Arguments: []byte(`{"command":"ls"}`)}); ok { + t.Fatal("a shell call is not a plan") + } + long := `{"todos":[{"content":"` + strings.Repeat("x", 800) + `","status":"pending"}]}` + if text, _, _ := planOf(record.Tool{Name: "TodoWrite", Arguments: []byte(long)}); len([]rune(text)) != planMaxRunes { + t.Fatalf("not capped: %d", len([]rune(text))) + } +} + +func TestTrackTasksBuildsThePlan(t *testing.T) { + st := &State{} + res := func(s string) *string { return &s } + call := func(name, args string, result *string) *PlanFold { + return trackTasks(st, record.Tool{Name: name, Arguments: []byte(args), Result: result}) + } + call("TaskCreate", `{"subject":"Write farewell()","activeForm":"Writing"}`, res("Task #1 created successfully: Write farewell()")) + call("TaskCreate", `{"subject":"Add a test"}`, res("Task #2 created successfully: Add a test")) + p := call("TaskUpdate", `{"taskId":"1","status":"in_progress"}`, res("Updated task #1 status")) + if p == nil || p.Text != "- [in_progress] Write farewell()\n- [pending] Add a test" || p.Status != "active" { + t.Fatalf("%+v", p) + } + call("TaskUpdate", `{"taskId":"1","status":"completed"}`, nil) + p = call("TaskUpdate", `{"taskId":2,"status":"completed"}`, nil) + if p == nil || p.Status != "done" { + t.Fatalf("%+v", p) + } + if p := call("TaskUpdate", `{"taskId":"9","status":"completed"}`, nil); p != nil { + t.Fatal("an unknown task changes nothing") + } + p = call("TaskUpdate", `{"taskId":"2","status":"deleted"}`, nil) + if p == nil || strings.Contains(p.Text, "Add a test") { + t.Fatalf("%+v", p) + } + if call("Read", `{"file_path":"x"}`, nil) != nil { + t.Fatal("not a task tool") + } +} + +func TestTaskCallsBecomeThePlanAtTurnEnd(t *testing.T) { + root := gitRepo(t) + _ = enabledStore(t, root) + transcript := filepath.Join(t.TempDir(), "s.jsonl") + _ = os.WriteFile(transcript, nil, 0o600) + d := &Dispatcher{Root: root, Version: "test", Now: time.Now, Deliver: Deliver("test", nil)} + sess := map[string]any{"Session": "s-plan", "Transcript": transcript} + runHook(t, d, "start", sess) + res := "Task #1 created successfully: Write farewell()" + // No prompt was delivered: the call is held for the turn's end. + runHook(t, d, "tool", map[string]any{"Session": "s-plan", "Transcript": transcript, + "Tool": &record.Tool{NativeID: "tc1", Name: "TaskCreate", Arguments: []byte(`{"subject":"Write farewell()"}`), Result: &res, Status: "completed"}}) + f, _ := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0o600) + _, _ = f.WriteString(`{"kind":"text","body":"OK."}` + "\n") + _ = f.Close() + runHook(t, d, "stop", sess) + mem, err := memrecord.Open(root, false) + if err != nil { + t.Fatal(err) + } + defer mem.Close() + es, _ := mem.Active(context.Background(), memrecord.KindPlan) + if len(es) != 1 || es[0].Content != "- [pending] Write farewell()" { + t.Fatalf("plan = %+v", es) + } +} diff --git a/internal/hooks/state.go b/internal/hooks/state.go index 3e7fec1..a580ec3 100644 --- a/internal/hooks/state.go +++ b/internal/hooks/state.go @@ -10,18 +10,22 @@ import ( "sort" "time" - "github.com/tryatlas/atlas-cli/internal/capture/store" + "golang.org/x/sys/unix" + + "github.com/tryatlas/atlas-cli/internal/capture/record" + "github.com/tryatlas/atlas-cli/internal/clistate" ) -// Session state lives under /.atlas/hooks//.json: -// what the dispatcher needs between two hook invocations of one session. -// .atlas/ ignores itself in git. +// Session state lives in the CLI's own state directory (clistate), under +// hooks//.json: what the dispatcher needs between two hook +// invocations of one session. Nothing of it goes into /.atlas/, which +// holds only what the desktop app writes. // Dir is the hooks state directory of a Project. -func Dir(root string) string { return filepath.Join(root, store.DirName, "hooks") } +func Dir(root string) string { return clistate.Path(root, "hooks") } // LogPath is where hook errors go. -func LogPath(root string) string { return filepath.Join(root, store.DirName, "cli-hooks.log") } +func LogPath(root string) string { return clistate.Path(root, "hooks.log") } // State is one native session as the hooks have seen it. type State struct { @@ -33,17 +37,40 @@ type State struct { UpdatedAt time.Time `json:"updated_at"` // Turn is the number of turns delivered so far. Turn int `json:"turn"` + // Tasks is the session's task list as an agent with incremental task + // tools (Claude Code's TaskCreate / TaskUpdate) built it. + Tasks []PlanTask `json:"tasks,omitempty"` + // TaskCalls are the task tool calls already applied (by call id), so a + // call seen by its hook and again in the transcript counts once. + TaskCalls []string `json:"task_calls,omitempty"` // InTurn is set between TurnStart and TurnEnd. InTurn bool `json:"in_turn,omitempty"` // Offset is the transcript position at the last turn boundary. Offset int `json:"offset"` // Prompt is the typed prompt of the open turn. Prompt string `json:"prompt,omitempty"` + // Head is HEAD at the baseline (turn start, then each shell call): + // commits after it were made during the turn. + Head string `json:"head,omitempty"` + // TurnHead is HEAD when the turn started: commits after it are linked + // once the turn's touches have landed. + TurnHead string `json:"turn_head,omitempty"` // Status is git status at turn start: path → true; nil when no snapshot // was taken (an empty map is a clean tree, so no omitempty). Status map[string]bool `json:"status"` // ToolFiles are files the agent's tool hooks reported during the turn. ToolFiles []string `json:"tool_files,omitempty"` + // Open says the turn was opened in the store at TurnStart (the prompt + // was delivered), so tool calls are delivered as they complete. + Open bool `json:"open,omitempty"` + // Delivered holds, per tool call delivered during the open turn, the + // keys that identify it (id, name+arguments hash, command, written + // paths), so the transcript read at turn end does not record it twice; + // a one-element group "path:" marks a path a delivered call wrote. + Delivered [][]string `json:"delivered,omitempty"` + // Tools are tool calls reported by hooks while no turn was open; they + // join the turn when it closes. + Tools []record.Tool `json:"tools,omitempty"` // Started is set once the session start was delivered (some agents // fire their first prompt hook before their session-start hook). Started bool `json:"started,omitempty"` @@ -89,7 +116,7 @@ func (s *State) Save(root string) error { if err != nil { return err } - tmp := p + ".tmp" + tmp := fmt.Sprintf("%s.%d.tmp", p, os.Getpid()) if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } @@ -206,3 +233,27 @@ func CurrentSession(root, agent string) string { } return "" } + +// lockSession takes an advisory lock on the session's state for the hook's +// lifetime; the lock file stays (an inode-bound lock must not be unlinked). +func lockSession(root, agent, id string) (func(), error) { + if !ValidSessionID(id) { + return nil, ErrBadSessionID + } + p := statePath(root, agent, id) + ".lock" + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return nil, err + } + f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // G304: the Project's own lock file + if err != nil { + return nil, err + } + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + _ = f.Close() + return nil, err + } + return func() { + _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/internal/hooks/state_main_test.go b/internal/hooks/state_main_test.go new file mode 100644 index 0000000..0a48392 --- /dev/null +++ b/internal/hooks/state_main_test.go @@ -0,0 +1,16 @@ +package hooks + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: keyword-only and deterministic + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/hosts/state_main_test.go b/internal/hosts/state_main_test.go new file mode 100644 index 0000000..5a4a8ca --- /dev/null +++ b/internal/hosts/state_main_test.go @@ -0,0 +1,15 @@ +package hosts + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/ingest/state_main_test.go b/internal/ingest/state_main_test.go new file mode 100644 index 0000000..52fbaae --- /dev/null +++ b/internal/ingest/state_main_test.go @@ -0,0 +1,15 @@ +package ingest + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 1e28f0a..ee74e3a 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -4,7 +4,7 @@ // owner-only Unix socket that carries the control endpoints: /sync for // atlas capture sync and retry, /hook for turns delivered by agent hooks, and // /mcp for the memory bridge of agents run in their own UIs (ADR-0013). Both -// are published in .atlas/cli-run.json. +// are published in the CLI's state directory (clistate) as run.json. package listener import ( @@ -15,17 +15,18 @@ import ( "net" "net/http" "os" - "path/filepath" "strconv" "sync/atomic" "syscall" "time" + "github.com/tryatlas/atlas-cli/internal/clistate" "github.com/tryatlas/atlas-cli/internal/memory/server" "github.com/tryatlas/atlas-cli/internal/timefmt" ) -// RunFile is /.atlas/cli-run.json while a listener runs. +// RunFile is the Project's run.json in the CLI state directory while a +// listener runs. type RunFile struct { PID int `json:"pid"` Port int `json:"port"` @@ -35,7 +36,7 @@ type RunFile struct { } // RunFilePath is where a Project's run file lives. -func RunFilePath(root string) string { return filepath.Join(root, ".atlas", "cli-run.json") } +func RunFilePath(root string) string { return clistate.Path(root, "run.json") } // ReadRunFile returns the running listener's file, or nil when none runs. func ReadRunFile(root string) *RunFile { @@ -161,11 +162,7 @@ func Start(root, version string, opt Options) (*Listener, error) { rf := RunFile{PID: os.Getpid(), Port: ln.Port, StartedAt: timefmt.RFC3339(time.Now()), Socket: sock, Daemon: opt.Daemon} b, _ := json.Marshal(rf) - if err := os.MkdirAll(filepath.Join(root, ".atlas"), 0o755); err != nil { - _ = ln.Stop() - return nil, err - } - if err := os.WriteFile(RunFilePath(root), b, 0o644); err != nil { //nolint:gosec // G306: holds no secret + if err := os.WriteFile(RunFilePath(root), b, 0o600); err != nil { _ = ln.Stop() return nil, err } diff --git a/internal/listener/state_main_test.go b/internal/listener/state_main_test.go new file mode 100644 index 0000000..a24d24f --- /dev/null +++ b/internal/listener/state_main_test.go @@ -0,0 +1,15 @@ +package listener + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/memory/corpus/corpus.go b/internal/memory/corpus/corpus.go new file mode 100644 index 0000000..b9f6ad2 --- /dev/null +++ b/internal/memory/corpus/corpus.go @@ -0,0 +1,431 @@ +// Package corpus is the memory retrieval index the desktop keeps in +// /.atlas/memory/corpus.sqlite (crates/atlas-memory/src/corpus.rs): +// the project's documents (its CLAUDE.md and AGENTS.md, knowledge notes, +// durable shared memories, captured sessions), each with a BM25 row and a +// MiniLM vector, searched by memory_search as "documents". The schema, ids, +// keys and vectors are the app's, so either side keeps the index the other +// built. The app's usearch file is a projection it rebuilds from +// embed_cache; the CLI flags it dirty after a change and searches the cache +// itself. +package corpus + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "unicode" + + "github.com/zeebo/blake3" + + "github.com/tryatlas/atlas-cli/internal/embed" + memrecord "github.com/tryatlas/atlas-cli/internal/memory/record" +) + +// File is the index's database inside .atlas/memory/. +const File = "corpus.sqlite" + +const schema = ` +CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS docs (id TEXT PRIMARY KEY, vkey INTEGER NOT NULL, content_hash TEXT NOT NULL, + corpus TEXT NOT NULL, title TEXT NOT NULL, source TEXT NOT NULL, text TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'repo'); +CREATE INDEX IF NOT EXISTS docs_vkey ON docs(vkey); +CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(title, text, content='', contentless_delete=1, + tokenize='porter unicode61 remove_diacritics 2'); +CREATE TABLE IF NOT EXISTS embed_cache (key BLOB PRIMARY KEY, model TEXT NOT NULL, dims INTEGER NOT NULL, vec BLOB NOT NULL); +` + +// Index is an open corpus.sqlite. +type Index struct { + db *sql.DB + Path string +} + +// Path is the project's corpus file. +func Path(root string) string { return filepath.Join(root, ".atlas", "memory", File) } + +// Open opens (with create, creates) the project's index. +func Open(root string, create bool) (*Index, error) { + p := Path(root) + if _, err := os.Stat(p); err != nil { + if !create { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return nil, err + } + } + esc := strings.NewReplacer("%", "%25", "?", "%3f", "#", "%23").Replace(p) + db, err := sql.Open("sqlite", "file:"+esc+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_txlock=immediate") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + _ = db.Close() + return nil, fmt.Errorf("corpus schema: %w", err) + } + return &Index{db: db, Path: p}, nil +} + +// Close closes it. +func (x *Index) Close() error { return x.db.Close() } + +// Doc is one document as a source yields it. +type Doc struct { + ID, Title, Text string + Source string // the corpus tag: shared, claude, codex, note, or an agent id +} + +// embedText is what is embedded and hashed: the title, then the text +// (memory_indexer.rs embed_text). +func (d Doc) embedText() string { + if strings.TrimSpace(d.Text) == "" { + return d.Title + } + return d.Title + "\n\n" + d.Text +} + +func hashText(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// cacheKey is blake3(model ‖ 0 ‖ content hash hex) (corpus.rs doc_cache_key). +func cacheKey(model, contentHash string) []byte { + h := blake3.New() + _, _ = h.Write([]byte(model)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(contentHash)) + return h.Sum(nil) +} + +// splitEmbedded is the stored title and body: cut at the first blank line, +// both trimmed (docstore.rs split_embedded). +func splitEmbedded(s string) (string, string) { + title, body, _ := strings.Cut(s, "\n\n") + return strings.TrimSpace(title), strings.TrimSpace(body) +} + +// Stats counts what a pass did. +type Stats struct { + Added, Updated, Deleted, Unchanged, Embedded int +} + +// Update brings the index in line with docs: unchanged documents (same hash, +// vector cached) are left alone, changed or new ones written (and embedded +// when emb is not nil and the vector is not cached), and stored documents +// owned reports as ours but that are no longer in docs deleted. Documents +// another writer owns (the app's readers of agent folders) are never +// touched. +func (x *Index) Update(ctx context.Context, docs []Doc, owned func(id string) bool, emb memrecord.VectorSource) (Stats, error) { + var st Stats + model := embed.ModelName + stored := map[string]string{} // id → content_hash + rows, err := x.db.QueryContext(ctx, `SELECT id, content_hash FROM docs`) + if err != nil { + return st, err + } + for rows.Next() { + var id, h string + if err := rows.Scan(&id, &h); err != nil { + _ = rows.Close() + return st, err + } + stored[id] = h + } + _ = rows.Close() + hasVec := func(h string) bool { + var one int + return x.db.QueryRowContext(ctx, `SELECT 1 FROM embed_cache WHERE key = ?`, cacheKey(model, h)).Scan(&one) == nil + } + type plan struct { + doc Doc + text string + hash string + vec []float32 + } + var upserts []plan + current := map[string]bool{} + for _, d := range docs { + if current[d.ID] { + continue + } + current[d.ID] = true + text := d.embedText() + h := hashText(text) + cached := hasVec(h) + if old, ok := stored[d.ID]; ok && old == h && (cached || emb == nil) { + st.Unchanged++ + continue + } + p := plan{doc: d, text: text, hash: h} + if !cached && emb != nil { + p.vec = emb.Embed(text) + st.Embedded++ + } + upserts = append(upserts, p) + } + var deletes []string + for id := range stored { + if !current[id] && owned(id) { + deletes = append(deletes, id) + } + } + sort.Strings(deletes) + if len(upserts) == 0 && len(deletes) == 0 { + return st, nil + } + tx, err := x.db.BeginTx(ctx, nil) + if err != nil { + return st, err + } + defer func() { _ = tx.Rollback() }() + changedVectors := false + for _, p := range upserts { + var rowid int64 + switch err := tx.QueryRowContext(ctx, `SELECT rowid FROM docs WHERE id = ?`, p.doc.ID).Scan(&rowid); { + case err == nil: + if _, err := tx.ExecContext(ctx, `DELETE FROM docs_fts WHERE rowid = ?`, rowid); err != nil { + return st, err + } + st.Updated++ + case errors.Is(err, sql.ErrNoRows): + st.Added++ + default: + return st, err + } + title, body := splitEmbedded(p.text) + scope := "repo" + if strings.HasSuffix(p.doc.ID, "@global") { + scope = "user" + } + if _, err := tx.ExecContext(ctx, `INSERT INTO docs (id, vkey, content_hash, corpus, title, source, text, scope) + VALUES (?1, ?2, ?3, ?4, ?5, ?4, ?6, ?7) + ON CONFLICT(id) DO UPDATE SET content_hash = excluded.content_hash, corpus = excluded.corpus, + title = excluded.title, source = excluded.source, text = excluded.text, scope = excluded.scope`, + p.doc.ID, embed.VKey(p.doc.ID), p.hash, p.doc.Source, title, body, scope); err != nil { + return st, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO docs_fts (rowid, title, text) SELECT rowid, title, text FROM docs WHERE id = ?`, p.doc.ID); err != nil { + return st, err + } + if p.vec != nil { + if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO embed_cache (key, model, dims, vec) VALUES (?, ?, ?, ?)`, + cacheKey(model, p.hash), model, len(p.vec), embed.Encode(p.vec)); err != nil { + return st, err + } + } + changedVectors = true + } + for _, id := range deletes { + var rowid int64 + if tx.QueryRowContext(ctx, `SELECT rowid FROM docs WHERE id = ?`, id).Scan(&rowid) == nil { + if _, err := tx.ExecContext(ctx, `DELETE FROM docs_fts WHERE rowid = ?`, rowid); err != nil { + return st, err + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM docs WHERE id = ?`, id); err != nil { + return st, err + } + st.Deleted++ + } + if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO meta (k, v) VALUES ('model', ?), ('dims', ?)`, model, fmt.Sprint(embed.Dim)); err != nil { + return st, err + } + if changedVectors || len(deletes) > 0 { + // The app's usearch file no longer matches: it rebuilds it from + // embed_cache on its next open (corpus.rs heal). + if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO meta (k, v) VALUES (?, '1')`, "vectors_dirty:"+model); err != nil { + return st, err + } + } + return st, tx.Commit() +} + +// Retrieval constants (retrieve.rs). +const ( + minCosine = 0.30 + rrfK = 60 + jaccardDedup = 0.8 + minQueryLen = 4 +) + +// Result is one document memory_search returns. +type Result struct { + ID, Title, Source, Text string +} + +// Retrieve is the documents leg of memory_search: dense (cosine over the +// cached vectors, at least 0.30) and BM25 over docs_fts, pool max(4·limit, +// 20) each, fused by reciprocal rank (k 60), near-duplicates (Jaccard ≥ 0.8 +// over words) dropped, at most limit. keep filters ids (live shared +// memories). qvec is the query's vector, nil without a model (then BM25 +// only). +func (x *Index) Retrieve(ctx context.Context, query string, qvec []float32, limit int, keep func(id string) bool) ([]Result, error) { + if len(strings.TrimSpace(query)) < minQueryLen || limit <= 0 { + return nil, nil + } + pool := max(limit*4, 20) + var dense []string + if qvec != nil { + type scored struct { + id string + sim float32 + } + var hits []scored + drows, err := x.db.QueryContext(ctx, `SELECT id, content_hash FROM docs`) + if err != nil { + return nil, err + } + type idHash struct{ id, hash string } + var all []idHash + for drows.Next() { + var ih idHash + if err := drows.Scan(&ih.id, &ih.hash); err != nil { + _ = drows.Close() + return nil, err + } + all = append(all, ih) + } + _ = drows.Close() + for _, ih := range all { + var vec []byte + if x.db.QueryRowContext(ctx, `SELECT vec FROM embed_cache WHERE key = ?`, cacheKey(embed.ModelName, ih.hash)).Scan(&vec) != nil { + continue + } + if sim := embed.Cosine(qvec, embed.Decode(vec)); sim >= minCosine { + hits = append(hits, scored{ih.id, sim}) + } + } + sort.SliceStable(hits, func(i, j int) bool { return hits[i].sim > hits[j].sim }) + for i, h := range hits { + if i >= pool { + break + } + dense = append(dense, h.id) + } + } + var bm []string + if fq := memrecord.FTSQuery(query); fq != "" { + rows, err := x.db.QueryContext(ctx, `SELECT d.id FROM docs_fts f JOIN docs d ON d.rowid = f.rowid + WHERE docs_fts MATCH ? ORDER BY f.rank LIMIT ?`, fq, pool) + if err != nil { + return nil, err + } + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + _ = rows.Close() + return nil, err + } + bm = append(bm, id) + } + _ = rows.Close() + } + score := map[string]float64{} + var order []string + add := func(list []string) { + for r, id := range list { + if _, ok := score[id]; !ok { + order = append(order, id) + } + score[id] += 1.0 / float64(rrfK+r+1) + } + } + add(dense) + add(bm) + sort.SliceStable(order, func(i, j int) bool { return score[order[i]] > score[order[j]] }) + var out []Result + var kept [][]string + for _, id := range order { + if keep != nil && !keep(id) { + continue + } + var r Result + if x.db.QueryRowContext(ctx, `SELECT id, title, source, text FROM docs WHERE id = ?`, id).Scan(&r.ID, &r.Title, &r.Source, &r.Text) != nil { + continue + } + words := tokens(r.Title + " " + r.Text) + dup := false + for _, k := range kept { + if jaccard(words, k) >= jaccardDedup { + dup = true + break + } + } + if dup { + continue + } + kept = append(kept, words) + r.Text = strings.TrimSpace(r.Text) + out = append(out, r) + if len(out) >= limit { + break + } + } + return out, nil +} + +func tokens(s string) []string { + var out []string + for _, w := range strings.Fields(s) { + w = strings.ToLower(strings.TrimFunc(w, func(r rune) bool { return !isAlnum(r) })) + if len(w) >= 2 { + out = append(out, w) + } + } + return out +} + +func jaccard(a, b []string) float64 { + sa, sb := map[string]bool{}, map[string]bool{} + for _, w := range a { + sa[w] = true + } + for _, w := range b { + sb[w] = true + } + inter := 0 + for w := range sa { + if sb[w] { + inter++ + } + } + union := len(sa) + len(sb) - inter + if union == 0 { + return 0 + } + return float64(inter) / float64(union) +} + +func isAlnum(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) } + +// Delete drops one document now (memory_forget evicts its shared doc). +func (x *Index) Delete(ctx context.Context, id string) error { + tx, err := x.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var rowid int64 + if tx.QueryRowContext(ctx, `SELECT rowid FROM docs WHERE id = ?`, id).Scan(&rowid) != nil { + return nil + } + if _, err := tx.ExecContext(ctx, `DELETE FROM docs_fts WHERE rowid = ?`, rowid); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM docs WHERE id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO meta (k, v) VALUES (?, '1')`, "vectors_dirty:"+embed.ModelName); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/memory/corpus/corpus_test.go b/internal/memory/corpus/corpus_test.go new file mode 100644 index 0000000..424d6ba --- /dev/null +++ b/internal/memory/corpus/corpus_test.go @@ -0,0 +1,174 @@ +package corpus + +import ( + "context" + "database/sql" + "hash/fnv" + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" + + memrecord "github.com/tryatlas/atlas-cli/internal/memory/record" +) + +type bag struct{} + +func (bag) Embed(text string) []float32 { + v := make([]float32, 64) + for _, w := range strings.Fields(strings.ToLower(text)) { + h := fnv.New32a() + _, _ = h.Write([]byte(strings.Trim(w, ".,:[]"))) + v[h.Sum32()%64]++ + } + var n float64 + for _, x := range v { + n += float64(x * x) + } + for i := range v { + if n > 0 { + v[i] = float32(float64(v[i]) / math.Sqrt(n)) + } + } + return v +} + +func TestUpdateAndRetrieve(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + ix, err := Open(root, true) + if err != nil { + t.Fatal(err) + } + defer ix.Close() + docs := []Doc{ + {ID: "shared:decision:1", Title: "Use Postgres for the ledger", Text: "[pi] Use Postgres for the ledger", Source: "shared"}, + {ID: "kb:deploy", Title: "deploy", Text: "Deploys go out on Fridays after the smoke tests.", Source: "note"}, + {ID: "claude:MEMORY.md", Title: "Memory Index", Text: "app-owned", Source: "claude"}, + } + st, err := ix.Update(ctx, docs, Owned, bag{}) + if err != nil || st.Added != 3 || st.Embedded != 3 { + t.Fatalf("%+v %v", st, err) + } + // Unchanged: nothing written. + if st, _ := ix.Update(ctx, docs, Owned, bag{}); st.Unchanged != 3 || st.Added+st.Updated+st.Embedded != 0 { + t.Fatalf("second pass %+v", st) + } + // A pass without the app-owned doc keeps it; a removed owned doc goes. + st, err = ix.Update(ctx, docs[:1], Owned, bag{}) + if err != nil || st.Deleted != 1 { + t.Fatalf("%+v %v", st, err) + } + var n int + _ = ix.db.QueryRow(`SELECT count(*) FROM docs WHERE id = 'claude:MEMORY.md'`).Scan(&n) + if n != 1 { + t.Fatal("an app-owned document must never be deleted by the CLI") + } + var dirty string + _ = ix.db.QueryRow(`SELECT v FROM meta WHERE k = 'vectors_dirty:all-MiniLM-L6-v2-384'`).Scan(&dirty) + if dirty != "1" { + t.Fatal("the app's vector file must be flagged for a rebuild") + } + res, err := ix.Retrieve(ctx, "ledger postgres", bag{}.Embed("ledger postgres"), 6, nil) + if err != nil || len(res) == 0 || res[0].ID != "shared:decision:1" || res[0].Source != "shared" { + t.Fatalf("%+v %v", res, err) + } + if res, _ := ix.Retrieve(ctx, "ab", nil, 6, nil); res != nil { + t.Fatal("a query under 4 bytes returns nothing") + } + if res, _ := ix.Retrieve(ctx, "ledger postgres", nil, 6, func(string) bool { return false }); len(res) != 0 { + t.Fatal("keep filters") + } + if err := ix.Delete(ctx, "shared:decision:1"); err != nil { + t.Fatal(err) + } + if res, _ := ix.Retrieve(ctx, "ledger postgres", nil, 6, nil); len(res) != 0 { + t.Fatalf("deleted doc returned: %+v", res) + } +} + +func TestHelpersMatchTheApp(t *testing.T) { + if got := shortTitle("first line\n---\nrest"); got != "first line" { + t.Fatal(got) + } + if got := shortTitle(strings.Repeat("ab ", 40)); !strings.HasSuffix(got, "…") || len([]rune(got)) != 81 { + t.Fatal(got) + } + in := "keep\n\nsecret\n\n--- SHARED MEMORY — UPDATES ---\nx\n--- END SHARED MEMORY ---\nalso keep" + if got := StripInjected(in); got != "keep\nalso keep" { + t.Fatalf("%q", got) + } + if t1, b1 := splitEmbedded("Title\n\n body \n\nmore"); t1 != "Title" || b1 != "body \n\nmore" { + t.Fatalf("%q %q", t1, b1) + } + if !Owned("opencode:ses_1") || Owned("claude:MEMORY.md") || Owned("codex:019a") || !Owned("claude:CLAUDE.md") || Owned("claude-code:x") { + t.Fatal("ownership") + } +} + +func TestSharedAndCaptureDocs(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + rec, err := memrecord.Open(root, true) + if err != nil { + t.Fatal(err) + } + _, _, _ = rec.Remember(ctx, memrecord.Write{Kind: "decision", Content: " Ship on Fridays ", Source: "agent", Agent: "pi", Session: "s", Confidence: 0.8, At: time.Now().UnixMilli(), LogEvent: true}) + _ = rec.Close() + _ = os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("Run make lint.\n"), 0o644) + docs, err := Collect(ctx, root, root) + if err != nil { + t.Fatal(err) + } + var shared, agents *Doc + for i := range docs { + switch { + case strings.HasPrefix(docs[i].ID, "shared:decision:"): + shared = &docs[i] + case docs[i].ID == "codex:AGENTS.md": + agents = &docs[i] + } + } + if shared == nil || shared.Text != "[pi] Ship on Fridays" || shared.Title != "Ship on Fridays" || agents == nil || agents.Text != "Run make lint." { + t.Fatalf("%+v", docs) + } +} + +// TestCollectMatchesTheAppsIndex runs Collect on a Project the app indexed +// (ATLAS_COMPAT_PROJECT) and checks every document both produce has the +// same content hash: the same text, byte for byte. +func TestCollectMatchesTheAppsIndex(t *testing.T) { + root := os.Getenv("ATLAS_COMPAT_PROJECT") + if root == "" { + t.Skip("set ATLAS_COMPAT_PROJECT to a Project the app has indexed") + } + ctx := context.Background() + docs, err := Collect(ctx, root, root) + if err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", "file:"+Path(root)+"?mode=ro") + if err != nil { + t.Fatal(err) + } + defer db.Close() + same, differ := 0, 0 + for _, d := range docs { + var h string + if db.QueryRow(`SELECT content_hash FROM docs WHERE id = ?`, d.ID).Scan(&h) != nil { + continue + } + if h == hashText(d.embedText()) { + same++ + } else { + differ++ + t.Logf("differs: %s", d.ID) + } + } + t.Logf("%d documents match the app's index, %d differ", same, differ) + if same == 0 { + t.Fatal("no document matched") + } +} diff --git a/internal/memory/corpus/sources.go b/internal/memory/corpus/sources.go new file mode 100644 index 0000000..f065961 --- /dev/null +++ b/internal/memory/corpus/sources.go @@ -0,0 +1,303 @@ +package corpus + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + capstore "github.com/tryatlas/atlas-cli/internal/capture/store" + memrecord "github.com/tryatlas/atlas-cli/internal/memory/record" +) + +// The desktop's caps on durable memories per kind (shared_memory.rs +// durable_entries); preferences are not documents. +var sharedCaps = []struct { + kind string + cap int +}{{"decision", 50}, {"failure", 30}, {"architecture", 30}, {"fact", 50}} + +// textCap bounds a captured session's text (agent_memory.rs TEXT_CAP). +const textCap = 12 * 1024 + +// Collect reads the documents the CLI indexes for the project at root +// (shared memory lives at scope): the project's CLAUDE.md and AGENTS.md, +// .atlas/knowledge notes, durable shared memories, and captured sessions of +// agents the app has no dedicated reader for, as the desktop's +// collect_corpus builds them. It never reads an agent's own folders (Claude +// Code's memory directory, ~/.claude, ~/.codex): the app indexes those into +// the same file, and Owned leaves them alone. +func Collect(ctx context.Context, root, scope string) ([]Doc, error) { + var docs []Doc + if t := projectFile(filepath.Join(root, "CLAUDE.md")); t != "" { + docs = append(docs, Doc{ID: "claude:CLAUDE.md", Title: "CLAUDE.md", Text: t, Source: "claude"}) + } + if t := projectFile(filepath.Join(root, "AGENTS.md")); t != "" { + docs = append(docs, Doc{ID: "codex:AGENTS.md", Title: "AGENTS.md", Text: t, Source: "codex"}) + } + docs = append(docs, knowledge(root)...) + shared, err := sharedDocs(ctx, scope) + if err != nil { + return nil, err + } + docs = append(docs, shared...) + captured, err := captureDocs(ctx, root) + if err != nil { + return nil, err + } + return append(docs, captured...), nil +} + +// Owned reports the documents Collect produces, which a pass may delete when +// they disappear; documents the app reads from agent folders (Claude memory +// files, global CLAUDE.md, Codex threads) are not the CLI's. +func Owned(id string) bool { + switch { + case id == "claude:CLAUDE.md" || id == "codex:AGENTS.md": + return true + case strings.HasPrefix(id, "shared:") || strings.HasPrefix(id, "kb:"): + return true + } + agent, _, ok := strings.Cut(id, ":") + return ok && !capturedElsewhere(agent) && agent != "claude" && agent != "codex" +} + +// capturedElsewhere: Claude Code and Codex sessions come from their own +// readers in the app, never from capture (agent_memory.rs +// capture_covered_agent). +func capturedElsewhere(agent string) bool { + return strings.HasPrefix(agent, "claude") || agent == "codex" +} + +// projectFile reads a project instruction file without Atlas's mirrored +// block or injected memory context. +func projectFile(path string) string { + b, err := os.ReadFile(path) //nolint:gosec // G304: the project's own instruction file + if err != nil { + return "" + } + return StripInjected(removeBlock(string(b))) +} + +// The block atlas-instruction-sync mirrors into AGENTS.md. +const ( + blockStart = "" + blockEnd = "" +) + +func removeBlock(s string) string { + lines := strings.SplitAfter(s, "\n") + start, end := -1, -1 + for i, l := range lines { + switch strings.TrimRight(l, "\r\n") { + case blockStart: + if start >= 0 { + return s // not one well-formed pair + } + start = i + case blockEnd: + if start < 0 || end >= 0 { + return s + } + end = i + } + } + if start < 0 || end < 0 { + return s + } + before := strings.Join(lines[:start], "") + after := strings.Join(lines[end+1:], "") + if before == "" { + after = strings.TrimPrefix(strings.TrimPrefix(after, "\r\n"), "\n") + } else if after == "" || strings.HasPrefix(after, "\n") || strings.HasPrefix(after, "\r\n") { + before = strings.TrimSuffix(strings.TrimSuffix(before, "\n"), "\r") + if !strings.HasSuffix(before, "\n") { + before += "\n" + } + } + return before + after +} + +// Context blocks Atlas injects into prompts, dropped from indexed text +// (atlas-agent-transcript strip_injected_context). +var injectedCores = []string{"SHARED MEMORY", "RELEVANT PROJECT MEMORY", "PROJECT MEMORY", "RECENT SESSION"} + +// StripInjected drops envelopes and "--- SHARED MEMORY ---" … +// "--- END SHARED MEMORY ---" style blocks, then trims. +func StripInjected(text string) string { + var out []string + skipUntil := "" + inEnvelope := false + for _, line := range strings.Split(text, "\n") { + l := strings.TrimSpace(line) + switch { + case inEnvelope: + if l == "" { + inEnvelope = false + } + continue + case skipUntil != "": + if l == skipUntil { + skipUntil = "" + } + continue + case l == "": + inEnvelope = true + continue + } + if strings.HasPrefix(l, "--- ") && strings.HasSuffix(l, "---") && !strings.HasPrefix(l, "--- END") { + inner := strings.TrimLeft(strings.TrimPrefix(l, "--- "), "") + for _, c := range injectedCores { + if strings.HasPrefix(inner, c) { + skipUntil = "--- END " + c + " ---" + break + } + } + if skipUntil != "" { + continue + } + } + out = append(out, strings.TrimSuffix(line, "\r")) + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + +// shortTitle is the first part before a "---" rule, whitespace collapsed, +// at most 80 characters and an ellipsis (agent_memory.rs short_title). +func shortTitle(s string) string { + cut, _, _ := strings.Cut(s, "\n---\n") + collapsed := strings.Join(strings.Fields(cut), " ") + if r := []rune(collapsed); len(r) > 80 { + return string(r[:80]) + "…" + } + return collapsed +} + +// knowledge is .atlas/knowledge/**/*.md: id kb:, the +// file name as title, source "note". +func knowledge(root string) []Doc { + base := filepath.Join(root, ".atlas", "knowledge") + var out []Doc + _ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { //nolint:gosec // G703: the project's own .atlas/knowledge + if err != nil || d.IsDir() || !strings.HasSuffix(p, ".md") { + return nil //nolint:nilerr // an unreadable note is skipped + } + b, err := os.ReadFile(p) //nolint:gosec // G304: the project's own notes + if err != nil || strings.TrimSpace(string(b)) == "" { + return nil //nolint:nilerr // an unreadable note is skipped + } + rel, _ := filepath.Rel(base, p) + rel = strings.TrimSuffix(filepath.ToSlash(rel), ".md") + out = append(out, Doc{ID: "kb:" + rel, Title: strings.TrimSuffix(d.Name(), ".md"), Text: string(b), Source: "note"}) + return nil + }) + return out +} + +// sharedDocs are the active durable memories with a log position, newest +// cap per kind, oldest first: id shared::, text +// "[] ". +func sharedDocs(ctx context.Context, scope string) ([]Doc, error) { + st, err := memrecord.Open(scope, false) + if err != nil { + return nil, nil //nolint:nilerr // no record yet: no shared documents + } + defer st.Close() + var out []Doc + for _, k := range sharedCaps { + es, err := st.DurableForCorpus(ctx, k.kind, k.cap) + if err != nil { + return nil, err + } + for _, e := range es { + t := strings.TrimSpace(e.Content) + if t == "" { + continue + } + out = append(out, Doc{ID: fmt.Sprintf("shared:%s:%d", e.Kind, e.ID), Title: shortTitle(t), Text: "[" + e.Agent + "] " + t, Source: "shared"}) + } + } + return out, nil +} + +// captureDocs are captured sessions of agents without a dedicated reader: +// "role: preview" lines (injection stripped) up to 12 KiB, id +// :, titled by the session's title or first +// prompt. +func captureDocs(ctx context.Context, root string) ([]Doc, error) { + if _, err := os.Stat(capstore.Path(root)); err != nil { //nolint:gosec // G703: the project's own store + return nil, nil //nolint:nilerr // capture never enabled here + } + db, err := sql.Open("sqlite", "file:"+capstore.Path(root)+"?mode=ro&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + defer db.Close() + rows, err := db.QueryContext(ctx, `SELECT id, agent, native_session_id, COALESCE(title, '') FROM agent_session + WHERE workspace_id = ? ORDER BY started_at`, root) + if err != nil { + return nil, err + } + type sess struct{ id, agent, native, title string } + var ss []sess + for rows.Next() { + var s sess + var agent, native sql.NullString + if err := rows.Scan(&s.id, &agent, &native, &s.title); err != nil { + _ = rows.Close() + return nil, err + } + s.agent, s.native = agent.String, native.String + if s.agent != "" && !capturedElsewhere(s.agent) { + ss = append(ss, s) + } + } + _ = rows.Close() + var out []Doc + for _, s := range ss { + mrows, err := db.QueryContext(ctx, `SELECT role, COALESCE(preview, '') FROM agent_message WHERE session_id = ? ORDER BY seq`, s.id) + if err != nil { + return nil, err + } + var text strings.Builder + firstUser := "" + for mrows.Next() { + var role, preview string + if err := mrows.Scan(&role, &preview); err != nil { + _ = mrows.Close() + return nil, err + } + clean := strings.TrimSpace(StripInjected(preview)) + if clean == "" { + continue + } + if firstUser == "" && role == "user" { + firstUser = clean + } + if text.Len() < textCap { + text.WriteString(role + ": ") + room := textCap - min(text.Len(), textCap) + r := []rune(clean) + text.WriteString(string(r[:min(len(r), room)])) + text.WriteByte('\n') + } + } + _ = mrows.Close() + titleSrc := strings.TrimSpace(StripInjected(s.title)) + if titleSrc == "" { + titleSrc = firstUser + } + body := text.String() + if strings.TrimSpace(titleSrc) == "" && strings.TrimSpace(body) == "" { + continue + } + if strings.TrimSpace(body) == "" { + body = titleSrc + } + out = append(out, Doc{ID: s.agent + ":" + s.native, Title: shortTitle(titleSrc), Text: body, Source: s.agent}) + } + return out, nil +} diff --git a/internal/memory/extract/extract.go b/internal/memory/extract/extract.go new file mode 100644 index 0000000..f3bc2bc --- /dev/null +++ b/internal/memory/extract/extract.go @@ -0,0 +1,418 @@ +// Package extract distils durable memories from a captured session through +// the Atlas AI gateway, as the desktop's extractor does (evidence-memory.md +// §4.1): after at least 20 turns and 3 tool calls since the last pass, and +// once at session end; only with sharing on and the summariser not set to +// "local"; results land as entries with source "extractor", through the +// record's redactor. Unlike the desktop, the excerpt sent is the capture +// store's text, which was redacted when it was recorded. +package extract + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "golang.org/x/sys/unix" + + "github.com/tryatlas/atlas-cli/internal/capture/store" + "github.com/tryatlas/atlas-cli/internal/clistate" + "github.com/tryatlas/atlas-cli/internal/memory/record" + "github.com/tryatlas/atlas-cli/internal/project" +) + +// Gates and limits (the desktop's). +const ( + MinTurns = 20 + MinToolCalls = 3 + ExcerptChars = 6000 + MaxEntries = 8 + Timeout = 60 * time.Second +) + +// Options name one session and how to reach the gateway. +type Options struct { + Root string // the Project root (capture store) + Agent string // the desktop's agent id + Session string // the agent's own session id + Final bool // the session ended: extract whatever is new + Gateway string // base URL, e.g. https://ai.tryatlas.cc/v1 + // Auth returns the bearer token and the active org id (may be ""). + Auth func(ctx context.Context) (token, org string, err error) + HTTP *http.Client + Now func() time.Time +} + +// ErrSkipped says a gate kept the pass from running. +var ErrSkipped = errors.New("extraction skipped") + +// state is the extractor's progress for one session, in the desktop's file +// and format (crates/atlas-memory/src/extract.rs ExtractState): +// /.atlas/memory/extract-state/.json. +type state struct { + LastTurn int64 `json:"last_extracted_turn_index"` + ToolCallsSince int `json:"tool_calls_since_last"` + ExtractionCount int `json:"extraction_count"` +} + +// sanitize keeps a session id filesystem-safe, as the desktop does. +func sanitize(s string) string { + return strings.Map(func(r rune) rune { + if r < 128 && (r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { + return r + } + return '_' + }, s) +} + +func statePath(scope, session string) string { + return filepath.Join(filepath.Dir(record.Path(scope)), "extract-state", sanitize(session)+".json") +} + +// legacyStatePath is where CLIs up to 0.4 kept it. +func legacyStatePath(root, session string) string { + return filepath.Join(clistate.Dir(root), "extract", session+".json") +} + +func loadState(scope, root, session string) state { + var s state + if b, err := os.ReadFile(statePath(scope, session)); err == nil { + _ = json.Unmarshal(b, &s) + return s + } + if b, err := os.ReadFile(legacyStatePath(root, session)); err == nil { + var old struct { + LastTurn int64 `json:"last_extracted_turn"` + ExtractionCount int `json:"extraction_count"` + } + if json.Unmarshal(b, &old) == nil { + s.LastTurn, s.ExtractionCount = old.LastTurn, old.ExtractionCount + } + } + return s +} + +// save writes atomically (temp file, rename), as the desktop does. +func (s state) save(scope, root, session string) error { + p := statePath(scope, session) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + b, _ := json.MarshalIndent(s, "", " ") + tmp := fmt.Sprintf("%s.%d.tmp", p, os.Getpid()) + if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { //nolint:gosec // G306: the app reads it; no secret + return err + } + if err := os.Rename(tmp, p); err != nil { + return err + } + _ = os.Remove(legacyStatePath(root, session)) + return nil +} + +// summariserLocal reads /.atlas/memory-summarizer.json: mode "local" +// sends nothing anywhere; "provider" (a BYOK model) is the desktop's alone. +func summariserMode(root string) string { + b, err := os.ReadFile(filepath.Join(root, store.DirName, "memory-summarizer.json")) //nolint:gosec // G304: the Project's own setting + if err != nil { + return "" + } + var v struct { + Mode string `json:"mode"` + } + _ = json.Unmarshal(b, &v) + return v.Mode +} + +// Run does one pass and returns how many entries it wrote. +func Run(ctx context.Context, o Options) (int, error) { + if o.Now == nil { + o.Now = time.Now + } + if o.HTTP == nil { + o.HTTP = &http.Client{Timeout: Timeout} + } + if !project.MemorySharing(o.Root) { + return 0, ErrSkipped + } + if m := summariserMode(o.Root); m == "local" || m == "provider" { + return 0, ErrSkipped + } + // One pass per session at a time: a pass that finds another running + // leaves the work to it. + unlock, ok := tryLock(o.Root, o.Session) + if !ok { + return 0, ErrSkipped + } + defer unlock() + scope := project.ScopeRoot(ctx, o.Root) + st := loadState(scope, o.Root, o.Session) + excerpt, last, tools, err := conversation(ctx, o.Root, o.Session, st.LastTurn) + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrSkipped // the session is not in this store (yet) + } + if err != nil { + return 0, err + } + turns := last - st.LastTurn + switch { + case turns <= 0 || strings.TrimSpace(excerpt) == "": + return 0, ErrSkipped + case !o.Final && (turns < MinTurns || tools < MinToolCalls): + if tools != st.ToolCallsSince { + st.ToolCallsSince = tools + _ = st.save(scope, o.Root, o.Session) + } + return 0, ErrSkipped + } + token, org, err := o.Auth(ctx) + if err != nil { + return 0, err + } + entries, err := ask(ctx, o, token, org, excerpt) + if err != nil { + return 0, err + } + mem, err := record.Open(scope, true) + if err != nil { + return 0, err + } + defer mem.Close() + n := 0 + at := o.Now().UnixMilli() + for _, e := range entries { + if n >= MaxEntries || !record.IsDurable(e.Kind) || strings.TrimSpace(e.Content) == "" { + continue + } + c := e.Confidence + if c <= 0 || c > 1 { + c = 0.5 + } + if _, _, err := mem.Remember(ctx, record.Write{Kind: e.Kind, Content: e.Content, Source: "extractor", + Agent: o.Agent, Session: o.Session, Confidence: c, At: at, LogEvent: true}); err != nil { + return n, err + } + n++ + } + st.LastTurn = last + st.ToolCallsSince = 0 + st.ExtractionCount++ + if o.Final && n > 0 { + // The handoff was written at session end, before this pass: rewrite + // it so the next session sees what was distilled. + _ = mem.WriteHandoff(ctx, o.Session, o.Agent, scope, o.Now().UnixMilli()) + } + return n, st.save(scope, o.Root, o.Session) +} + +var injected = regexp.MustCompile(`(?s)<(system-reminder|local-command-caveat|local-command-stdout|command-message|task-notification)[^>]*>.*?`) + +// conversation is the session's text after a turn, from the capture store: +// prompts, replies and the tools called, the last ExcerptChars of it, plus +// the session's last turn and the number of tool calls since. +func conversation(ctx context.Context, root, native string, after int64) (string, int64, int, error) { + st, err := store.Open(root, false) + if err != nil { + return "", 0, 0, err + } + defer st.Close() + db := st.DB() + var id string + if err := db.QueryRowContext(ctx, `SELECT id FROM agent_session WHERE workspace_id = ? AND native_session_id = ? + ORDER BY updated_at DESC LIMIT 1`, root, native).Scan(&id); err != nil { + return "", 0, 0, err + } + var last int64 + _ = db.QueryRowContext(ctx, `SELECT COALESCE(MAX(turn_seq), 0) FROM turn WHERE session_id = ?`, id).Scan(&last) + var tools int + _ = db.QueryRowContext(ctx, `SELECT count(*) FROM tool_call WHERE session_id = ? AND turn_seq > ?`, id, after).Scan(&tools) + rows, err := db.QueryContext(ctx, `SELECT 'm', role, COALESCE(body, ''), seq FROM agent_message + WHERE session_id = ? AND turn_seq > ? AND mode = 'text' + UNION ALL SELECT 't', tool_name, COALESCE(title, ''), seq FROM tool_call WHERE session_id = ? AND turn_seq > ? + ORDER BY 4`, id, after, id, after) + if err != nil { + return "", 0, 0, err + } + defer rows.Close() + var b strings.Builder + for rows.Next() { + var typ, who, text string + var seq int64 + if rows.Scan(&typ, &who, &text, &seq) != nil { + continue + } + text = strings.TrimSpace(injected.ReplaceAllString(text, "")) + if typ == "t" { + fmt.Fprintf(&b, "[tool %s] %s\n", who, firstLine(text)) + continue + } + if text == "" { + continue + } + label := "Assistant" + if who == "user" { + label = "User" + } + fmt.Fprintf(&b, "%s: %s\n\n", label, text) + } + s := b.String() + if r := []rune(s); len(r) > ExcerptChars { + s = string(r[len(r)-ExcerptChars:]) + } + return s, last, tools, rows.Err() +} + +func firstLine(s string) string { + s, _, _ = strings.Cut(s, "\n") + if len(s) > 160 { + s = s[:160] + } + return s +} + +// Entry is one memory the model proposes. +type Entry struct { + Kind string `json:"kind"` + Content string `json:"content"` + Confidence float64 `json:"confidence"` +} + +// Prompt is what the model is asked; the excerpt follows it. +const Prompt = `You distil durable project memory from a coding-agent session in a software repository. +Read the session excerpt below and return only a JSON object, no prose: +{"entries":[{"kind":"decision|fact|failure|architecture|preference","content":"...","confidence":0.0}]} + +Keep only what a future session in this repository needs and could not cheaply rediscover from the code: +- decision: a choice that was made and why +- fact: a durable truth about the project, its tools or its environment +- failure: a dead end or mistake, with the rule to follow next time +- architecture: how parts of the system fit together +- preference: something the user asked to be done a certain way from now on +Each content is one or two self-contained sentences. No session narration, no task status, no file lists, no secrets. +confidence is how sure you are the memory is true and durable (0 to 1). +Return {"entries":[]} when nothing qualifies. At most 8 entries. + +Session excerpt: +` + +func ask(ctx context.Context, o Options, token, org, excerpt string) ([]Entry, error) { + model, err := defaultModel(ctx, o, token, org) + if err != nil { + return nil, err + } + body, _ := json.Marshal(map[string]any{"model": model, "stream": false, + "messages": []map[string]string{{"role": "user", "content": Prompt + excerpt}}}) + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := call(ctx, o, token, org, http.MethodPost, "/chat/completions", body, &out); err != nil { + return nil, err + } + if len(out.Choices) == 0 { + return nil, errors.New("the gateway returned no choice") + } + return parseEntries(out.Choices[0].Message.Content) +} + +// parseEntries reads {"entries":[…]} out of a reply that may wrap it in a +// code fence or prose. +func parseEntries(text string) ([]Entry, error) { + i, j := strings.Index(text, "{"), strings.LastIndex(text, "}") + if i < 0 || j <= i { + return nil, fmt.Errorf("no JSON object in the reply") + } + var v struct { + Entries []Entry `json:"entries"` + } + if err := json.Unmarshal([]byte(text[i:j+1]), &v); err != nil { + return nil, err + } + return v.Entries, nil +} + +// defaultModel is the gateway's model flagged default (the desktop's +// "gateway default model"). +func defaultModel(ctx context.Context, o Options, token, org string) (string, error) { + var out struct { + Data []struct { + ID string `json:"id"` + Default bool `json:"default"` + } `json:"data"` + } + if err := call(ctx, o, token, org, http.MethodGet, "/models", nil, &out); err != nil { + return "", err + } + for _, m := range out.Data { + if m.Default { + return m.ID, nil + } + } + if len(out.Data) > 0 { + return out.Data[0].ID, nil + } + return "", errors.New("the gateway lists no model") +} + +func call(ctx context.Context, o Options, token, org, method, path string, body []byte, out any) error { + var r io.Reader + if body != nil { + r = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(o.Gateway, "/")+path, r) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + if org != "" { + req.Header.Set("atlas-org", org) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := o.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gateway %s %s: %d", method, path, resp.StatusCode) + } + return json.Unmarshal(b, out) +} + +// tryLock takes the session's extraction lock without waiting. +func tryLock(root, session string) (func(), bool) { + p := legacyStatePath(root, session) + ".lock" // the CLI's own: .atlas/ holds only the app's files + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return nil, false + } + f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // G304: the CLI's own lock file + if err != nil { + return nil, false + } + if unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) != nil { + _ = f.Close() + return nil, false + } + return func() { + _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) + _ = f.Close() + }, true +} diff --git a/internal/memory/extract/extract_test.go b/internal/memory/extract/extract_test.go new file mode 100644 index 0000000..d4be306 --- /dev/null +++ b/internal/memory/extract/extract_test.go @@ -0,0 +1,140 @@ +package extract + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/tryatlas/atlas-cli/internal/capture/record" + "github.com/tryatlas/atlas-cli/internal/capture/store" + memrecord "github.com/tryatlas/atlas-cli/internal/memory/record" +) + +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: deterministic + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// gateway answers /models and /chat/completions like the Atlas gateway. +func gateway(t *testing.T, seen *string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" || r.Header.Get("atlas-org") != "org_1" { + http.Error(w, "auth", http.StatusUnauthorized) + return + } + switch r.URL.Path { + case "/v1/models": + fmt.Fprint(w, `{"data":[{"id":"big","default":false},{"id":"small","default":true}]}`) + case "/v1/chat/completions": + var req struct { + Model string `json:"model"` + Messages []struct{ Content string } `json:"messages"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + *seen = req.Model + "\n" + req.Messages[0].Content + fmt.Fprint(w, `{"choices":[{"message":{"content":"`+"```json\\n"+`{\"entries\":[{\"kind\":\"decision\",\"content\":\"Use the streaming parser for large files.\",\"confidence\":0.8},{\"kind\":\"bogus\",\"content\":\"x\"}]}`+"\\n```"+`"}}]}`) + default: + http.NotFound(w, r) + } + })) +} + +func TestExtractGatesAndWrites(t *testing.T) { + ctx := context.Background() + root, _ := os.MkdirTemp("", "atlas-x-") + defer os.RemoveAll(root) + st, err := store.Open(root, true) + if err != nil { + t.Fatal(err) + } + _ = st.PutBinding(ctx, store.Binding{WorkspaceID: root, Root: root, Mode: store.ModeLocal, Enabled: true}, time.Now()) + rec := record.New(st, root) + sess := record.Session{Agent: "claude-acp", NativeID: "s-x", Cwd: root} + for i := 0; i < 3; i++ { + args, _ := json.Marshal(map[string]string{"command": "go test"}) + if _, err := rec.RecordTurn(ctx, sess, record.CapturedTurn{Prompt: fmt.Sprintf("step %d", i), + Items: []record.CapturedItem{{Mode: "text", Body: "ran the tests hidden"}, + {Tool: &record.Tool{NativeID: fmt.Sprintf("t%d", i), Name: "Bash", Arguments: args, Status: "completed"}}}}); err != nil { + t.Fatal(err) + } + } + _ = st.Close() + var seen string + gw := gateway(t, &seen) + defer gw.Close() + o := Options{Root: root, Agent: "claude-acp", Session: "s-x", Gateway: gw.URL + "/v1", + Auth: func(context.Context) (string, string, error) { return "tok", "org_1", nil }} + + // 3 turns: under the mid-session gate. + if _, err := Run(ctx, o); !errors.Is(err, ErrSkipped) { + t.Fatalf("mid-session pass = %v, want skipped", err) + } + // Session end: extract whatever is new. + o.Final = true + n, err := Run(ctx, o) + if err != nil || n != 1 { + t.Fatalf("final pass = %d %v", n, err) + } + if !strings.HasPrefix(seen, "small\n") || !strings.Contains(seen, "User: step 2") || !strings.Contains(seen, "[tool Bash]") || strings.Contains(seen, "hidden") { + t.Fatalf("request = %q", seen) + } + mem, err := memrecord.Open(root, false) + if err != nil { + t.Fatal(err) + } + defer mem.Close() + es, _ := mem.Active(ctx, "decision") + if len(es) != 1 || es[0].Source != "extractor" || es[0].Agent != "claude-acp" || es[0].Session != "s-x" { + t.Fatalf("entries = %+v", es) + } + // Nothing new since: skipped. + if _, err := Run(ctx, o); !errors.Is(err, ErrSkipped) { + t.Fatalf("repeat = %v", err) + } + // Progress is kept where and how the desktop keeps it. + b, err := os.ReadFile(filepath.Join(root, ".atlas", "memory", "extract-state", "s-x.json")) + if err != nil { + t.Fatal(err) + } + var got map[string]any + _ = json.Unmarshal(b, &got) + if got["last_extracted_turn_index"] != float64(3) || got["tool_calls_since_last"] != float64(0) || got["extraction_count"] != float64(1) { + t.Fatalf("state = %s", b) + } + // An unknown session is skipped, not an error. + o.Session = "not-captured" + if _, err := Run(ctx, o); !errors.Is(err, ErrSkipped) { + t.Fatalf("unknown session = %v", err) + } +} + +func TestSanitizeMatchesTheDesktop(t *testing.T) { + if got := sanitize("ses_ab/c:d.é-1"); got != "ses_ab_c_d__-1" { + t.Fatal(got) + } +} + +func TestLocalSummariserSendsNothing(t *testing.T) { + root := t.TempDir() + _ = os.MkdirAll(root+"/.atlas", 0o755) + _ = os.WriteFile(root+"/.atlas/memory-summarizer.json", []byte(`{"mode":"local"}`), 0o644) + called := false + o := Options{Root: root, Session: "s", Final: true, Gateway: "http://127.0.0.1:1", + Auth: func(context.Context) (string, string, error) { called = true; return "", "", nil }} + if _, err := Run(context.Background(), o); !errors.Is(err, ErrSkipped) || called { + t.Fatalf("local mode: %v called=%v", err, called) + } +} diff --git a/internal/memory/record/handoff.go b/internal/memory/record/handoff.go new file mode 100644 index 0000000..3763194 --- /dev/null +++ b/internal/memory/record/handoff.go @@ -0,0 +1,125 @@ +package record + +import ( + "context" + "encoding/json" + "path/filepath" + "regexp" + "strings" +) + +// ── Handoff notes and capture candidates (the desktop's session lifecycle) ── + +// HandoffNote is what a finished session leaves for the next one: the +// episodes row's note, camelCase like the desktop's. +type HandoffNote struct { + Session string `json:"session"` + Agent string `json:"agent"` + StartedAt *int64 `json:"startedAt"` + EndedAt int64 `json:"endedAt"` + Plan *string `json:"plan"` + OpenItems []string `json:"openItems"` + Decisions []string `json:"decisions"` + Failures []string `json:"failures"` + Facts []string `json:"facts"` + Architecture []string `json:"architecture"` + Files []string `json:"files"` +} + +// handoffCap bounds each list of a note. +const handoffCap = 10 + +// WriteHandoff builds the session's handoff note from the entries it wrote +// and stores it as its episode (replacing an earlier one). A session that +// wrote nothing leaves no note. +func (s *Store) WriteHandoff(ctx context.Context, session, agent, root string, endedAt int64) error { + entries, err := s.EntriesBySessions(ctx, []string{session}, 400) + if err != nil || len(entries) == 0 { + return err + } + n := HandoffNote{Session: session, Agent: agent, EndedAt: endedAt, + OpenItems: []string{}, Decisions: []string{}, Failures: []string{}, Facts: []string{}, Architecture: []string{}, Files: []string{}} + var started int64 + if s.db.QueryRowContext(ctx, `SELECT started_at FROM sessions WHERE session_id = ?`, session).Scan(&started) == nil && started > 0 { + n.StartedAt = &started + } + add := func(list *[]string, v string) { + if v = strings.TrimSpace(v); v != "" && len(*list) < handoffCap { + *list = append(*list, v) + } + } + for _, e := range entries { + if e.State != StateActive { + continue // candidates are not the session's word yet + } + switch e.Kind { + case KindPlan: + if n.Plan == nil { + c := e.Content + n.Plan = &c + } + case "decision": + add(&n.Decisions, e.Content) + case "failure": + add(&n.Failures, e.Content) + case "fact": + add(&n.Facts, e.Content) + case "architecture": + add(&n.Architecture, e.Content) + case KindFileChanged: + p := e.Key + if p != "" && !filepath.IsAbs(p) && root != "" { + p = filepath.Join(root, filepath.FromSlash(p)) + } + add(&n.Files, p) + } + } + b, err := json.Marshal(n) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO episodes (session, agent, started_at, ended_at, note) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session) DO UPDATE SET agent = excluded.agent, ended_at = excluded.ended_at, note = excluded.note`, + session, agent, n.StartedAt, endedAt, string(b)) + return err +} + +// markers are the phrases in an agent's own reply that the desktop records +// as candidate memories (memory_delta.rs): never active until confirmed. +var markers = []struct { + kind string + re *regexp.Regexp +}{ + {"decision", regexp.MustCompile(`(?i)\b(decided to\b|decision:|we will use\b|let's use\b|going with\b)`)}, + {"fact", regexp.MustCompile(`(?i)\b(note:|remember:|convention:)`)}, + {"failure", regexp.MustCompile(`(?i)\b(failed:|doesn't work\b|does not work\b|anti-pattern\b|gotcha:)`)}, + {"architecture", regexp.MustCompile(`(?i)\b(architecture:|structured as\b|the system uses\b)`)}, +} + +// candidateMax bounds one captured line. +const candidateMax = 500 + +// CaptureCandidates records each line of an agent's reply that carries a +// marker as a candidate of the marker's kind (source capture, confidence +// 0.3). It returns how many were written. +func (s *Store) CaptureCandidates(ctx context.Context, text, agent, session string, at int64) (int, error) { + n := 0 + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*>#0123456789. ")) + if len(line) < 12 || len(line) > candidateMax { + continue + } + for _, m := range markers { + if !m.re.MatchString(line) { + continue + } + if _, _, err := s.Remember(ctx, Write{Kind: m.kind, Content: line, Source: "capture", Agent: agent, + Session: session, Confidence: Candidate, At: at}); err != nil { + return n, err + } + n++ + break + } + } + return n, nil +} diff --git a/internal/memory/record/handoff_test.go b/internal/memory/record/handoff_test.go new file mode 100644 index 0000000..d67fc1a --- /dev/null +++ b/internal/memory/record/handoff_test.go @@ -0,0 +1,55 @@ +package record + +import ( + "context" + "encoding/json" + "testing" +) + +func TestCaptureCandidatesAndHandoff(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + st, err := Open(root, true) + if err != nil { + t.Fatal(err) + } + defer st.Close() + _ = st.SessionStart(ctx, "s1", "claude-acp", 1000) + reply := "Done.\n- We decided to keep the parser streaming.\nGotcha: the cache key must include the locale.\nDecision: ship the CLI for Intel Macs too.\nnothing to see here at all\n" + n, err := st.CaptureCandidates(ctx, reply, "claude-acp", "s1", 2000) + if err != nil || n != 3 { + t.Fatalf("candidates = %d %v", n, err) + } + es, _ := st.List(ctx, "", 50) + kinds := map[string]string{} + for _, e := range es { + kinds[e.Kind] = e.State + if e.Source != "capture" || e.Agent != "claude-acp" { + t.Fatalf("entry %+v", e) + } + } + if kinds["decision"] != StateCandidate || kinds["failure"] != StateCandidate { + t.Fatalf("kinds = %v (candidates must never be active)", kinds) + } + if _, err := st.Fold(ctx, Event{Kind: KindFileChanged, Key: "a.go", Text: "Edit", Agent: "claude-acp", Session: "s1", At: 2500}); err != nil { + t.Fatal(err) + } + if err := st.WriteHandoff(ctx, "s1", "claude-acp", "/repo", 3000); err != nil { + t.Fatal(err) + } + raw, err := st.LatestHandoff(ctx, "s2") + if err != nil || raw == nil { + t.Fatalf("handoff = %s %v", raw, err) + } + var note HandoffNote + if err := json.Unmarshal(raw, ¬e); err != nil { + t.Fatal(err) + } + if note.Session != "s1" || note.Agent != "claude-acp" || note.StartedAt == nil || *note.StartedAt != 1000 || note.EndedAt != 3000 || + len(note.Decisions) != 0 || len(note.Failures) != 0 || len(note.Files) != 1 || note.Files[0] != "/repo/a.go" || note.OpenItems == nil { + t.Fatalf("note = %+v", note) + } + if raw, _ := st.LatestHandoff(ctx, "s1"); raw != nil { + t.Fatal("a session never sees its own handoff") + } +} diff --git a/internal/memory/record/read.go b/internal/memory/record/read.go index b3a0100..2f15f15 100644 --- a/internal/memory/record/read.go +++ b/internal/memory/record/read.go @@ -183,20 +183,24 @@ func FTSQuery(q string) string { // Hit is a search result with its BM25 rank (1-based; 0 when not from BM25). type Hit struct { Entry *Entry - BM25 int + BM25 int // 1-based rank in the keyword leg; 0 when it did not find it + Dense int // 1-based rank in the meaning leg } // rrfK is the reciprocal-rank-fusion constant. const rrfK = 60 -// Search ranks entries: the BM25 leg (depth 100) fused with two priors that -// only reorder what was found — trust (active before candidate, then -// confidence, then id; weight 0.5) and recency (weight 0.3). Archived entries -// are excluded. An empty query returns the newest entries. +// Search is the app's hybrid search (record.rs search_explained): the +// keyword leg (FTS5 BM25, 100 deep) and the meaning leg (cosine over cached +// vectors, 100 deep, at least 0.35) fused by reciprocal rank (k 60, weight 1 +// each), then priors that only reorder what was found: trust (active before +// candidate, then confidence, then id; weight 0.5) and recency (the later of +// last use and update; weight 0.3). Archived entries are excluded. Without a +// model the meaning leg is empty. An empty query returns the newest entries. func (s *Store) Search(ctx context.Context, query string, kinds []string, limit int) ([]Hit, error) { kq, kargs := kindFilter(kinds) fq := FTSQuery(query) - if fq == "" { + if strings.TrimSpace(query) == "" { es, err := s.queryEntries(ctx, `WHERE state <> 'archived'`+kq+` ORDER BY updated_at DESC, id DESC LIMIT ?`, append(kargs, limit)...) hits := make([]Hit, len(es)) for i, e := range es { @@ -204,20 +208,41 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit } return hits, err } - rows, err := s.db.QueryContext(ctx, `SELECT rowid FROM entries_fts WHERE entries_fts MATCH ? ORDER BY rank LIMIT 100`, fq) - if err != nil { - return nil, err + var bmIDs []int64 + if fq != "" { + rows, err := s.db.QueryContext(ctx, `SELECT rowid FROM entries_fts WHERE entries_fts MATCH ? ORDER BY rank LIMIT ?`, fq, legDepth) + if err != nil { + return nil, err + } + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + _ = rows.Close() + return nil, err + } + bmIDs = append(bmIDs, id) + } + _ = rows.Close() } - var ids []int64 - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - _ = rows.Close() + var denseIDs []int64 + if v := vectorFor(ctx, s.db, strings.TrimSpace(query)); v != nil { + var err error + if denseIDs, err = s.denseIDs(ctx, v); err != nil { return nil, err } + } + bm, dn := map[int64]int{}, map[int64]int{} + var ids []int64 + for i, id := range bmIDs { + bm[id] = i + 1 ids = append(ids, id) } - _ = rows.Close() + for i, id := range denseIDs { + dn[id] = i + 1 + if bm[id] == 0 { + ids = append(ids, id) + } + } if len(ids) == 0 { return nil, nil } @@ -230,19 +255,20 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit if err != nil { return nil, err } - bm := map[int64]int{} - for i, id := range ids { - bm[id] = i + 1 - } score := map[int64]float64{} for _, e := range found { - score[e.ID] += 1.0 / float64(rrfK+bm[e.ID]) + if r := bm[e.ID]; r > 0 { + score[e.ID] += 1.0 / float64(rrfK+r) + } + if r := dn[e.ID]; r > 0 { + score[e.ID] += 1.0 / float64(rrfK+r) + } } trust := append([]*Entry(nil), found...) sort.SliceStable(trust, func(i, j int) bool { a, b := trust[i], trust[j] - if (a.State == StateActive) != (b.State == StateActive) { - return a.State == StateActive + if (a.State == StateCandidate) != (b.State == StateCandidate) { + return b.State == StateCandidate } if a.Confidence != b.Confidence { return a.Confidence > b.Confidence @@ -252,8 +278,20 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit for i, e := range trust { score[e.ID] += 0.5 / float64(rrfK+i+1) } + lastTouch := func(e *Entry) int64 { + t := e.UpdatedAt + if e.LastUsedAt != nil && *e.LastUsedAt > t { + t = *e.LastUsedAt + } + return t + } recent := append([]*Entry(nil), found...) - sort.SliceStable(recent, func(i, j int) bool { return recent[i].UpdatedAt > recent[j].UpdatedAt }) + sort.SliceStable(recent, func(i, j int) bool { + if lastTouch(recent[i]) != lastTouch(recent[j]) { + return lastTouch(recent[i]) > lastTouch(recent[j]) + } + return recent[i].ID < recent[j].ID + }) for i, e := range recent { score[e.ID] += 0.3 / float64(rrfK+i+1) } @@ -263,9 +301,12 @@ func (s *Store) Search(ctx context.Context, query string, kinds []string, limit } return found[i].ID < found[j].ID }) + if limit > 0 && len(found) > limit { + found = found[:limit] + } hits := make([]Hit, len(found)) for i, e := range found { - hits[i] = Hit{Entry: e, BM25: bm[e.ID]} + hits[i] = Hit{Entry: e, BM25: bm[e.ID], Dense: dn[e.ID]} } return hits, nil } diff --git a/internal/memory/record/snapshot.go b/internal/memory/record/snapshot.go new file mode 100644 index 0000000..784c97d --- /dev/null +++ b/internal/memory/record/snapshot.go @@ -0,0 +1,61 @@ +package record + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strconv" +) + +// SnapshotIfDue refreshes memory.snapshot.sqlite when the last one is a day +// old or missing, as the desktop's health pass does (crates/atlas-memory/ +// src/health.rs snapshot_if_due): VACUUM INTO a temp file, stamp it with a +// snapshot_meta 'at' row (the age is read from there, not the mtime), then +// rename it into place. A damaged record is restored from it on open. +func (s *Store) SnapshotIfDue(now int64) (bool, error) { + dir := filepath.Dir(Path(s.ScopeRoot)) + path := filepath.Join(dir, SnapshotFile) + if at, ok := snapshotAt(path); ok && now-at < SnapshotEvery.Milliseconds() { + return false, nil + } + tmp := path + ".tmp" + _ = os.Remove(tmp) + if _, err := s.db.Exec(`VACUUM INTO ?`, tmp); err != nil { + return false, err + } + snap, err := sql.Open("sqlite", "file:"+tmp) + if err != nil { + return false, err + } + _, err = snap.Exec(`CREATE TABLE IF NOT EXISTS snapshot_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)`) + if err == nil { + _, err = snap.Exec(`INSERT OR REPLACE INTO snapshot_meta (k, v) VALUES ('at', ?)`, strconv.FormatInt(now, 10)) + } + if cerr := snap.Close(); err == nil { + err = cerr + } + if err != nil { + _ = os.Remove(tmp) + return false, fmt.Errorf("stamp snapshot: %w", err) + } + return true, os.Rename(tmp, path) +} + +// snapshotAt reads the snapshot's 'at' stamp. +func snapshotAt(path string) (int64, bool) { + if _, err := os.Stat(path); err != nil { + return 0, false + } + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + return 0, false + } + defer db.Close() + var v string + if db.QueryRow(`SELECT v FROM snapshot_meta WHERE k = 'at'`).Scan(&v) != nil { + return 0, false + } + at, err := strconv.ParseInt(v, 10, 64) + return at, err == nil +} diff --git a/internal/memory/record/snapshot_test.go b/internal/memory/record/snapshot_test.go new file mode 100644 index 0000000..59f2434 --- /dev/null +++ b/internal/memory/record/snapshot_test.go @@ -0,0 +1,62 @@ +package record + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSnapshotAndRestore(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + st, err := Open(root, true) + if err != nil { + t.Fatal(err) + } + now := time.Now().UnixMilli() + if _, _, err := st.Remember(ctx, Write{Kind: "decision", Content: "Keep the record restorable", Source: "agent", Agent: "pi", Session: "s1", Confidence: 0.9, At: now}); err != nil { + t.Fatal(err) + } + if did, err := st.SnapshotIfDue(now); err != nil || !did { + t.Fatalf("first snapshot: %v %v", did, err) + } + if did, _ := st.SnapshotIfDue(now + time.Hour.Milliseconds()); did { + t.Fatal("a snapshot is due once a day") + } + if at, ok := snapshotAt(filepath.Join(root, ".atlas", "memory", SnapshotFile)); !ok || at != now { + t.Fatalf("stamp = %d %v", at, ok) + } + _ = st.Close() + + // Damage the record: the next open quarantines it and restores the snapshot. + dir := filepath.Join(root, ".atlas", "memory") + for _, f := range []string{"-wal", "-shm"} { + _ = os.Remove(filepath.Join(dir, DBFile+f)) + } + if err := os.WriteFile(filepath.Join(dir, DBFile), []byte("this is not a database, not at all, definitely not one"), 0o644); err != nil { + t.Fatal(err) + } + st, err = Open(root, true) + if err != nil { + t.Fatalf("open after damage: %v", err) + } + defer st.Close() + es, _ := st.Active(ctx, "decision") + if len(es) != 1 || es[0].Content != "Keep the record restorable" { + t.Fatalf("restored = %+v", es) + } + if b, err := os.ReadFile(filepath.Join(dir, "restored.json")); err != nil || string(b) == "" { + t.Fatal("no restored.json marker") + } + if m, _ := filepath.Glob(filepath.Join(dir, DBFile+".corrupt-*")); len(m) != 1 { + t.Fatalf("quarantined = %v", m) + } +} + +func TestAnOldSchemaIsNotQuarantined(t *testing.T) { + if damaged(ErrSchemaTooOld) || damaged(ErrNoRecord) { + t.Fatal("a declined record is not damaged") + } +} diff --git a/internal/memory/record/stale_test.go b/internal/memory/record/stale_test.go new file mode 100644 index 0000000..df52ce6 --- /dev/null +++ b/internal/memory/record/stale_test.go @@ -0,0 +1,33 @@ +package record + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStaleAfterTheRecordIsRecreated(t *testing.T) { + root := t.TempDir() + st, err := Open(root, true) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if st.Stale() { + t.Fatal("fresh store is not stale") + } + if err := os.RemoveAll(filepath.Join(root, ".atlas")); err != nil { + t.Fatal(err) + } + if !st.Stale() { + t.Fatal("a deleted record must be stale") + } + again, err := Open(root, true) + if err != nil { + t.Fatal(err) + } + defer again.Close() + if !st.Stale() || again.Stale() { + t.Fatal("the old handle is stale, the new one is not") + } +} diff --git a/internal/memory/record/state_main_test.go b/internal/memory/record/state_main_test.go new file mode 100644 index 0000000..f116ef1 --- /dev/null +++ b/internal/memory/record/state_main_test.go @@ -0,0 +1,16 @@ +package record + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: keyword-only and deterministic + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/memory/record/store.go b/internal/memory/record/store.go index e82723a..db6c138 100644 --- a/internal/memory/record/store.go +++ b/internal/memory/record/store.go @@ -11,6 +11,8 @@ import ( "os" "path/filepath" "strings" + "syscall" + "time" _ "modernc.org/sqlite" // pure-Go SQLite with FTS5 ) @@ -93,8 +95,40 @@ const ( type Store struct { ScopeRoot string db *sql.DB + opened os.FileInfo // memory.sqlite as opened + held *os.File // a handle on it: unlinked means .atlas/ went away } +// Stale reports that memory.sqlite on disk is no longer the file this store +// opened (.atlas/ deleted or re-created): a long-lived holder (the listener, +// an mcp-bridge) must reopen it, or its writes go to a file no one sees. +func (s *Store) Stale() bool { + now, err := os.Stat(Path(s.ScopeRoot)) + return err != nil || s.opened == nil || unlinked(s.held) || !os.SameFile(s.opened, now) +} + +// unlinked: the open file's link count is 0. A re-created file can reuse +// the inode number (ext4 does), so the inode comparison alone misses it. +func unlinked(f *os.File) bool { + if f == nil { + return false + } + fi, err := f.Stat() + if err != nil { + return true + } + st, ok := fi.Sys().(*syscall.Stat_t) + return ok && st.Nlink == 0 +} + +// File names in /.atlas/memory/, as the desktop names them. +const ( + DBFile = "memory.sqlite" + SnapshotFile = "memory.snapshot.sqlite" + // SnapshotEvery is how old the snapshot may get (health.rs SNAPSHOT_EVERY_MS). + SnapshotEvery = 24 * time.Hour +) + // Path is /.atlas/memory/memory.sqlite. func Path(scopeRoot string) string { return filepath.Join(scopeRoot, ".atlas", "memory", "memory.sqlite") @@ -104,6 +138,58 @@ func Path(scopeRoot string) string { // created at schema v6. A record below v6 was made by an older desktop and is // refused rather than overwritten. func Open(scopeRoot string, create bool) (*Store, error) { + st, err := openChecked(scopeRoot, create) + if err == nil || !damaged(err) { + return st, err + } + // A damaged record is quarantined and the last snapshot copied in its + // place, with a restored.json marker for the app, as the desktop does + // (crates/atlas-memory/src/record.rs RecordStore::open). + if qerr := quarantine(scopeRoot, time.Now().UnixMilli()); qerr != nil { + return nil, fmt.Errorf("%w (and quarantining it failed: %w)", err, qerr) + } + return openChecked(scopeRoot, create) +} + +// damaged tells a damaged file from a record the CLI declines (wrong schema +// version, not there). +func damaged(err error) bool { + if errors.Is(err, ErrNoRecord) || errors.Is(err, ErrSchemaTooNew) || errors.Is(err, ErrSchemaTooOld) { + return false + } + m := strings.ToLower(err.Error()) + for _, w := range []string{"malformed", "not a database", "quick_check", "corrupt", "disk image"} { + if strings.Contains(m, w) { + return true + } + } + return false +} + +func quarantine(scopeRoot string, now int64) error { + dir := filepath.Dir(Path(scopeRoot)) + for _, suffix := range []string{"", "-wal", "-shm"} { + from := filepath.Join(dir, DBFile+suffix) + if _, err := os.Stat(from); err == nil { + if err := os.Rename(from, filepath.Join(dir, fmt.Sprintf("%s%s.corrupt-%d", DBFile, suffix, now))); err != nil { + return err + } + } + } + fromSnapshot := copyFile(filepath.Join(dir, SnapshotFile), filepath.Join(dir, DBFile)) == nil + b, _ := json.Marshal(map[string]any{"at": now, "from_snapshot": fromSnapshot}) + return os.WriteFile(filepath.Join(dir, "restored.json"), b, 0o644) //nolint:gosec // G306: a marker the app reads +} + +func copyFile(from, to string) error { + b, err := os.ReadFile(from) //nolint:gosec // G304: the record's own snapshot + if err != nil { + return err + } + return os.WriteFile(to, b, 0o644) //nolint:gosec // G306: the record, as the app writes it +} + +func openChecked(scopeRoot string, create bool) (*Store, error) { path := Path(scopeRoot) if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { if !create { @@ -124,6 +210,16 @@ func Open(scopeRoot string, create bool) (*Store, error) { _ = db.Close() return nil, err } + var quick string + if err := db.QueryRow(`PRAGMA quick_check`).Scan(&quick); err != nil || quick != "ok" { + _ = db.Close() + if err == nil { + err = fmt.Errorf("quick_check: %s", quick) + } + return nil, err + } + s.opened, _ = os.Stat(path) + s.held, _ = os.Open(path) //nolint:gosec // G304: the record's own file return s, nil } @@ -162,7 +258,12 @@ func (s *Store) migrate() error { } // Close closes the record. -func (s *Store) Close() error { return s.db.Close() } +func (s *Store) Close() error { + if s.held != nil { + _ = s.held.Close() + } + return s.db.Close() +} // VerifyChain recomputes the revision hash chain. func (s *Store) VerifyChain() (ChainReport, error) { return verifyChain(s.db) } diff --git a/internal/memory/record/vectors.go b/internal/memory/record/vectors.go new file mode 100644 index 0000000..47cf672 --- /dev/null +++ b/internal/memory/record/vectors.go @@ -0,0 +1,312 @@ +package record + +import ( + "context" + "database/sql" + "errors" + "sort" + "strings" + "unicode" + + "github.com/tryatlas/atlas-cli/internal/embed" +) + +// VectorSource embeds text; the record caches what it returns. +type VectorSource interface { + Embed(text string) []float32 +} + +// Vectors returns the embedder, or nil when the model is not on this +// machine: then search is keyword-only and dedup is key-or-hash only, as +// in the app. Tests replace it. +var Vectors = func() VectorSource { + e, err := embed.Default() + if err != nil { + return nil + } + return e +} + +// VectorModel is the name vectors are cached under. +const VectorModel = embed.ModelName + +// NearDuplicate is the cosine at which a new durable memory merges into a +// stored one of its kind (record.rs NEAR_DUPLICATE). +const NearDuplicate = 0.92 + +// Search legs (record.rs LEG_DEPTH, MIN_SIMILARITY). +const ( + legDepth = 100 + minSimilarity = 0.35 +) + +type vecDB interface { + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// cachedVector reads text's vector from embed_cache. +func cachedVector(ctx context.Context, q vecDB, text string) []float32 { + var vec []byte + if q.QueryRowContext(ctx, `SELECT vec FROM embed_cache WHERE key = ?`, embed.CacheKey(VectorModel, text)).Scan(&vec) != nil { + return nil + } + return embed.Decode(vec) +} + +// putVector caches text's vector, as the app's put_cache. +func putVector(ctx context.Context, q vecDB, text string, v []float32) error { + _, err := q.ExecContext(ctx, `INSERT OR REPLACE INTO embed_cache (key, model, dims, vec) VALUES (?, ?, ?, ?)`, + embed.CacheKey(VectorModel, text), VectorModel, len(v), embed.Encode(v)) + return err +} + +// vectorFor is text's vector: cached, else embedded (nil without a model, +// or for a text with nothing to embed). +func vectorFor(ctx context.Context, q vecDB, text string) []float32 { + if v := cachedVector(ctx, q, text); v != nil { + return v + } + src := Vectors() + if src == nil { + return nil + } + v := src.Embed(text) + for _, x := range v { + if x != 0 { + return v + } + } + return nil +} + +// nearDuplicate is the stored entry of w's kind most like v at or above +// NearDuplicate; keyed entries only merge with keyless ones, and a text +// that contradicts (another number, one side negated) never merges. +func nearDuplicate(ctx context.Context, tx *sql.Tx, w Write, v []float32) (*Entry, error) { + rows, err := tx.QueryContext(ctx, `SELECT id, key, content FROM entries WHERE kind = ? AND state <> 'archived'`, w.Kind) + if err != nil { + return nil, err + } + type cand struct { + id int64 + key, content string + } + var cs []cand + for rows.Next() { + var c cand + if err := rows.Scan(&c.id, &c.key, &c.content); err != nil { + _ = rows.Close() + return nil, err + } + cs = append(cs, c) + } + _ = rows.Close() + var best int64 + bestSim := float32(0) + for _, c := range cs { + if w.Key != "" && c.key != "" || ContradictsText(w.Content, c.content) { + continue + } + stored := cachedVector(ctx, tx, c.content) + if stored == nil { + continue + } + if sim := embed.Cosine(v, stored); sim >= NearDuplicate && sim > bestSim { + best, bestSim = c.id, sim + } + } + if best == 0 { + return nil, nil + } + return scanOne(tx.QueryRowContext(ctx, `SELECT `+entryColumns+` FROM entries WHERE id = ?`, best)) +} + +var negations = []string{"not", "never", "no longer", "don't", "doesn't", "isn't", "instead of"} + +// ContradictsText is the app's conflict signal (consolidate.rs +// contradicts_text): the texts differ in their numbers or one is negated. +func ContradictsText(a, b string) bool { + na, nb := numbers(a), numbers(b) + if strings.Join(na, "\x00") != strings.Join(nb, "\x00") { + return true + } + return negated(a) != negated(b) +} + +func numbers(s string) []string { + parts := strings.FieldsFunc(s, func(r rune) bool { return (r < '0' || r > '9') && r != '.' }) + var out []string + for _, p := range parts { + if strings.ContainsAny(p, "0123456789") { + out = append(out, strings.Trim(p, ".")) + } + } + sort.Strings(out) + return out +} + +func negated(s string) bool { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r == '\'' || r == '‘' || r == '’': + b.WriteRune('\'') + case isAlnum(r): + b.WriteRune(r) + default: + b.WriteByte(' ') + } + } + padded := " " + strings.Join(strings.Fields(b.String()), " ") + " " + for _, n := range negations { + if strings.Contains(padded, " "+n+" ") { + return true + } + } + return false +} + +// denseIDs are the entries whose cached vector is within minSimilarity of +// v, best first, at most legDepth (the app searches an HNSW built from the +// same cache; this reads it exactly). +func (s *Store) denseIDs(ctx context.Context, v []float32) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, content FROM entries WHERE state <> 'archived'`) + if err != nil { + return nil, err + } + type idText struct { + id int64 + text string + } + var all []idText + for rows.Next() { + var x idText + if err := rows.Scan(&x.id, &x.text); err != nil { + _ = rows.Close() + return nil, err + } + all = append(all, x) + } + _ = rows.Close() + type scored struct { + id int64 + sim float32 + } + var hits []scored + for _, x := range all { + stored := cachedVector(ctx, s.db, x.text) + if stored == nil { + continue + } + if sim := embed.Cosine(v, stored); sim >= minSimilarity { + hits = append(hits, scored{x.id, sim}) + } + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].sim != hits[j].sim { + return hits[i].sim > hits[j].sim + } + return hits[i].id < hits[j].id + }) + if len(hits) > legDepth { + hits = hits[:legDepth] + } + ids := make([]int64, len(hits)) + for i, h := range hits { + ids[i] = h.id + } + return ids, nil +} + +// SyncVectors embeds every durable entry without a cached vector (the app's +// sync_vectors backfill) and drops cached vectors no entry uses any more +// (garbage_keys). It returns how many it embedded; ErrNoVectors without a +// model. +func (s *Store) SyncVectors(ctx context.Context) (int, error) { + src := Vectors() + if src == nil { + return 0, ErrNoVectors + } + rows, err := s.db.QueryContext(ctx, `SELECT kind, content FROM entries`) + if err != nil { + return 0, err + } + live := map[string]bool{} + var todo []string + for rows.Next() { + var kind, content string + if err := rows.Scan(&kind, &content); err != nil { + _ = rows.Close() + return 0, err + } + key := string(embed.CacheKey(VectorModel, content)) + if live[key] { + continue + } + live[key] = true + if IsDurable(kind) { + todo = append(todo, content) + } + } + _ = rows.Close() + n := 0 + for _, text := range todo { + if cachedVector(ctx, s.db, text) != nil { + continue + } + v := src.Embed(strings.TrimSpace(text)) + if v == nil { + continue + } + if err := putVector(ctx, s.db, text, v); err != nil { + return n, err + } + n++ + } + // Garbage: cache keys of this model that no entry's text produces. + krows, err := s.db.QueryContext(ctx, `SELECT key FROM embed_cache WHERE model = ?`, VectorModel) + if err != nil { + return n, err + } + var dead [][]byte + for krows.Next() { + var k []byte + if err := krows.Scan(&k); err != nil { + _ = krows.Close() + return n, err + } + if len(k) != 32 || !live[string(k)] { + dead = append(dead, k) + } + } + _ = krows.Close() + for _, k := range dead { + if _, err := s.db.ExecContext(ctx, `DELETE FROM embed_cache WHERE key = ?`, k); err != nil { + return n, err + } + } + return n, nil +} + +// ErrNoVectors says there is no model to embed with. +var ErrNoVectors = errors.New("no embedding model on this machine") + +func isAlnum(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) } + +// DurableForCorpus is one kind's active memories with a log position, the +// newest n, oldest first (the desktop's durable_entries for the corpus). +func (s *Store) DurableForCorpus(ctx context.Context, kind string, n int) ([]*Entry, error) { + es, err := s.queryEntries(ctx, `WHERE kind = ? AND seq IS NOT NULL ORDER BY seq DESC LIMIT ?`, kind, n) + if err != nil { + return nil, err + } + var out []*Entry + for i := len(es) - 1; i >= 0; i-- { + if es[i].State == StateActive { + out = append(out, es[i]) + } + } + return out, nil +} diff --git a/internal/memory/record/vectors_test.go b/internal/memory/record/vectors_test.go new file mode 100644 index 0000000..8bd1b6f --- /dev/null +++ b/internal/memory/record/vectors_test.go @@ -0,0 +1,162 @@ +package record + +import ( + "context" + "hash/fnv" + "math" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/tryatlas/atlas-cli/internal/embed" +) + +// bagOfWords is a deterministic stand-in for the model: a hashed bag of +// lowercase words, unit length. Same words, same vector. +type bagOfWords struct{} + +func (bagOfWords) Embed(text string) []float32 { + v := make([]float32, 64) + for _, w := range strings.Fields(strings.ToLower(text)) { + h := fnv.New32a() + _, _ = h.Write([]byte(strings.Trim(w, ".,"))) + v[h.Sum32()%64]++ + } + var n float64 + for _, x := range v { + n += float64(x * x) + } + if n == 0 { + return v + } + for i := range v { + v[i] = float32(float64(v[i]) / math.Sqrt(n)) + } + return v +} + +func withVectors(t *testing.T, src VectorSource) { + old := Vectors + Vectors = func() VectorSource { return src } + t.Cleanup(func() { Vectors = old }) +} + +func TestRememberCachesVectorsAndMergesNearDuplicates(t *testing.T) { + withVectors(t, bagOfWords{}) + ctx := context.Background() + st, err := Open(t.TempDir(), true) + if err != nil { + t.Fatal(err) + } + defer st.Close() + at := time.Now().UnixMilli() + w := Write{Kind: "decision", Content: "Use Postgres for the ledger service", Source: "agent", Agent: "pi", Session: "s1", Confidence: 0.8, At: at} + if o, _, err := st.Remember(ctx, w); err != nil || o != Inserted { + t.Fatalf("%s %v", o, err) + } + if cachedVector(ctx, st.db, w.Content) == nil { + t.Fatal("a durable write caches its vector") + } + // The same words in another order: a near-duplicate, merged. + w2 := w + w2.Content, w2.Session, w2.At = "For the ledger service use Postgres", "s2", at+1 + o, e, err := st.Remember(ctx, w2) + if err != nil || o != Merged || e.Uses != 1 { + t.Fatalf("near-duplicate: %s %+v %v", o, e, err) + } + // A contradicting text is stored, never merged. + w3 := w + w3.Content, w3.Session, w3.At = "Do not use Postgres for the ledger service", "s3", at+2 + if o, _, _ := st.Remember(ctx, w3); o != Inserted { + t.Fatalf("contradiction merged: %s", o) + } + // Plans and file changes are not embedded. + if _, _, err := st.Remember(ctx, Write{Kind: "plan", Content: "Step one", Source: "agent", Agent: "pi", Session: "s1", At: at}); err != nil { + t.Fatal(err) + } + if cachedVector(ctx, st.db, "Step one") != nil { + t.Fatal("a plan has no vector") + } +} + +func TestSearchFusesTheMeaningLeg(t *testing.T) { + withVectors(t, bagOfWords{}) + ctx := context.Background() + st, err := Open(t.TempDir(), true) + if err != nil { + t.Fatal(err) + } + defer st.Close() + at := time.Now().UnixMilli() + for i, c := range []string{"Ledger writes go through Postgres", "The cache is Redis", "Deploys run on Fridays"} { + if _, _, err := st.Remember(ctx, Write{Kind: "fact", Content: c, Source: "agent", Agent: "pi", Session: "s", Confidence: 0.7, At: at + int64(i)}); err != nil { + t.Fatal(err) + } + } + hits, err := st.Search(ctx, "ledger postgres", nil, 10) + if err != nil || len(hits) == 0 || hits[0].Entry.Content != "Ledger writes go through Postgres" || hits[0].BM25 != 1 || hits[0].Dense != 1 { + t.Fatalf("%+v %v", hits, err) + } + // Without a model: keyword only, same top hit, no dense rank. + withVectors(t, nil) + hits, _ = st.Search(ctx, "ledger postgres", nil, 10) + if len(hits) == 0 || hits[0].Dense != 0 { + t.Fatalf("%+v", hits) + } +} + +func TestSyncVectorsBackfillsAndCollectsGarbage(t *testing.T) { + withVectors(t, nil) + ctx := context.Background() + st, _ := Open(t.TempDir(), true) + defer st.Close() + _, _, _ = st.Remember(ctx, Write{Kind: "fact", Content: "Builds use Go 1.26", Source: "agent", Agent: "pi", Session: "s", At: 1}) + _ = putVector(ctx, st.db, "a text no entry has", []float32{1, 0}) + withVectors(t, bagOfWords{}) + n, err := st.SyncVectors(ctx) + if err != nil || n != 1 { + t.Fatalf("%d %v", n, err) + } + if cachedVector(ctx, st.db, "Builds use Go 1.26") == nil || cachedVector(ctx, st.db, "a text no entry has") != nil { + t.Fatal("backfill and garbage") + } +} + +func TestContradictsText(t *testing.T) { + for _, c := range []struct { + a, b string + want bool + }{ + {"Timeout is 30s", "Timeout is 60s", true}, + {"We use JWT", "We don't use JWT", true}, + {"We use JWT", "JWT is what we use", false}, + {"Retry 3 times", "Retry 3 times.", false}, + } { + if got := ContradictsText(c.a, c.b); got != c.want { + t.Errorf("%q vs %q = %v", c.a, c.b, got) + } + } +} + +// TestRealModelFindsMeaning runs only where the app downloaded the model. +func TestRealModelFindsMeaning(t *testing.T) { + e, err := embed.Load(filepath.Join(embed.AppDataDir(), "models", embed.ModelID)) + if err != nil { + t.Skip("the model is not on this machine") + } + withVectors(t, e) + ctx := context.Background() + st, _ := Open(t.TempDir(), true) + defer st.Close() + at := time.Now().UnixMilli() + for i, c := range []string{"Signing keys for access tokens rotate every week", "The UI uses a monochrome palette", "CI runs golangci-lint before tests"} { + if _, _, err := st.Remember(ctx, Write{Kind: "decision", Content: c, Source: "agent", Agent: "pi", Session: "s", Confidence: 0.8, At: at + int64(i)}); err != nil { + t.Fatal(err) + } + } + hits, err := st.Search(ctx, "how often do we change JWT secrets", nil, 3) + if err != nil || len(hits) == 0 || !strings.HasPrefix(hits[0].Entry.Content, "Signing keys") || hits[0].Dense != 1 { + t.Fatalf("%+v %v", hits, err) + } +} diff --git a/internal/memory/record/write.go b/internal/memory/record/write.go index de68f61..6b673b5 100644 --- a/internal/memory/record/write.go +++ b/internal/memory/record/write.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "strings" + + "github.com/tryatlas/atlas-cli/internal/embed" ) // Revision ops. @@ -85,6 +87,12 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) { return "", nil, errors.New("nothing to remember: content is empty") } hash := ContentHash(w.Content) + // A durable write is embedded before the transaction (as the app does, + // outside its lock); without a model it is stored all the same. + var vec []float32 + if IsDurable(w.Kind) { + vec = vectorFor(ctx, s.db, strings.TrimSpace(w.Content)) + } tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -106,6 +114,15 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) { if err != nil { return "", nil, err } + nearDup := false + if cur == nil && vec != nil && !w.OnlyNew { + // No key or text match: a stored memory of the kind that says the + // same thing takes the write as a merge. + if cur, err = nearDuplicate(ctx, tx, w, vec); err != nil { + return "", nil, err + } + nearDup = cur != nil + } var outcome string var id int64 @@ -125,7 +142,7 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) { case w.OnlyNew: return "", cur, nil - case cur.ContentHash == hash: + case cur.ContentHash == hash || nearDup: if isRetry(cur, w) { return Merged, cur, nil } @@ -166,6 +183,11 @@ func (s *Store) Remember(ctx context.Context, w Write) (string, *Entry, error) { return "", nil, err } } + if vec != nil && (outcome == Inserted || outcome == Replaced) { + if err := putVector(ctx, tx, w.Content, vec); err != nil { + return "", nil, err + } + } op := w.Op if op == "" { op = map[string]string{Inserted: OpInsert, Replaced: OpReplace, Merged: OpMerge}[outcome] @@ -424,9 +446,49 @@ func (s *Store) Forget(ctx context.Context, id int64, session string, at int64) if _, err := tx.ExecContext(ctx, `DELETE FROM entries WHERE id = ?`, e.ID); err != nil { return false, err } + // Its vectors go too, for every text it ever had (record.rs purge). + if err := dropVectors(ctx, tx, e); err != nil { + return false, err + } return true, tx.Commit() } +// dropVectors deletes the cached vectors of an entry's current and earlier +// texts, under every model the cache holds. +func dropVectors(ctx context.Context, tx *sql.Tx, e *Entry) error { + texts := map[string]bool{e.Content: true} + rows, err := tx.QueryContext(ctx, `SELECT content FROM revisions WHERE entry_id = ?`, e.ID) + if err == nil { + for rows.Next() { + var c string + if rows.Scan(&c) == nil { + texts[c] = true + } + } + _ = rows.Close() + } + mrows, err := tx.QueryContext(ctx, `SELECT DISTINCT model FROM embed_cache`) + if err != nil { + return err + } + var models []string + for mrows.Next() { + var m string + if mrows.Scan(&m) == nil { + models = append(models, m) + } + } + _ = mrows.Close() + for _, m := range models { + for t := range texts { + if _, err := tx.ExecContext(ctx, `DELETE FROM embed_cache WHERE key = ?`, embed.CacheKey(m, t)); err != nil { + return err + } + } + } + return nil +} + // carryingEvents are the entry's own event and the events of its kind with // its key (keyed) or its content hash (keyless). func carryingEvents(ctx context.Context, tx *sql.Tx, e *Entry) ([]int64, error) { diff --git a/internal/memory/server/server.go b/internal/memory/server/server.go index d592460..b9ba4a1 100644 --- a/internal/memory/server/server.go +++ b/internal/memory/server/server.go @@ -87,7 +87,11 @@ func (s *Server) store(ctx context.Context, cwd string) (*record.Store, error) { s.mu.Lock() defer s.mu.Unlock() if st, ok := s.stores[scope]; ok { - return st, nil + if !st.Stale() { + return st, nil + } + _ = st.Close() // .atlas/ was deleted or re-created: open the new record + delete(s.stores, scope) } st, err := record.Open(scope, true) if err != nil { @@ -114,6 +118,7 @@ func (s *Server) SessionStarted(ctx context.Context, g Grant) { } if st, err := s.store(ctx, g.Cwd); err == nil { _ = st.SessionStart(ctx, g.SessionID, g.Agent, s.now()) + _, _ = st.SnapshotIfDue(s.now()) // the desktop's daily backup, at most once a day } } @@ -146,7 +151,22 @@ func (s *Server) SessionEnded(ctx context.Context, g Grant) { return } if st, err := s.store(ctx, g.Cwd); err == nil { - _ = st.SessionEnd(ctx, g.SessionID, g.Agent, s.now()) + now := s.now() + _ = st.SessionEnd(ctx, g.SessionID, g.Agent, now) + // The handoff note the next session's briefing shows, as the + // desktop's lifecycle writes it. + _ = st.WriteHandoff(ctx, g.SessionID, g.Agent, project.ScopeRoot(ctx, g.Cwd), now) + } +} + +// Capture records the marked lines of an agent's reply as candidate +// memories when sharing is on (the desktop's memory_delta). +func (s *Server) Capture(ctx context.Context, g Grant, reply string) { + if g.SessionID == "" || strings.TrimSpace(reply) == "" || !sharingOn(ctx, g.Cwd) { + return + } + if st, err := s.store(ctx, g.Cwd); err == nil { + _, _ = st.CaptureCandidates(ctx, reply, g.Agent, g.SessionID, s.now()) } } diff --git a/internal/memory/server/state_main_test.go b/internal/memory/server/state_main_test.go new file mode 100644 index 0000000..dd47a42 --- /dev/null +++ b/internal/memory/server/state_main_test.go @@ -0,0 +1,16 @@ +package server + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + _ = os.Setenv("ATLAS_MODELS_DIR", dir) // no model: keyword-only and deterministic + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/memory/server/tools.go b/internal/memory/server/tools.go index 450ddf2..7494ece 100644 --- a/internal/memory/server/tools.go +++ b/internal/memory/server/tools.go @@ -4,12 +4,16 @@ import ( "context" "encoding/json" "errors" + "fmt" "math" "path/filepath" "regexp" "sort" + "strconv" "strings" + "time" + "github.com/tryatlas/atlas-cli/internal/memory/corpus" "github.com/tryatlas/atlas-cli/internal/memory/record" "github.com/tryatlas/atlas-cli/internal/project" ) @@ -361,12 +365,71 @@ func (t *call) search(args json.RawMessage) (any, error) { entries := []any{} for _, x := range list { m := t.entryJSON(x.h.Entry, srcs[x.h.Entry.ID], x.ev) + why := map[string]any{} if x.h.BM25 > 0 { - m["why"] = map[string]any{"bm25": x.h.BM25} + why["bm25"] = x.h.BM25 + } + if x.h.Dense > 0 { + why["dense"] = x.h.Dense + } + if len(why) > 0 { + m["why"] = why } entries = append(entries, m) } - return map[string]any{"entries": entries, "documents": []any{}}, nil + documents := []any{} + if len(a.Kinds) == 0 { + docLimit := indexDefault + if a.Limit != nil { + docLimit = min(max(*a.Limit, 1), indexMax) + } + for _, d := range t.documents(a.Query, docLimit) { + documents = append(documents, map[string]any{"title": d.Title, "source": d.Source, "text": d.Text}) + } + } + return map[string]any{"entries": entries, "documents": documents}, nil +} + +// How many indexed documents memory_search adds (tools.rs INDEX_*_LIMIT). +const ( + indexDefault = 6 + indexMax = 20 +) + +// documents is the index leg of memory_search: the project's corpus, best +// effort (no index, no model: BM25 only or nothing). A shared memory's +// document is returned only while its entry still exists. +func (t *call) documents(query string, limit int) []corpus.Result { + root := t.g.Cwd + if p, err := project.Discover(t.ctx, t.g.Cwd); err == nil { + root = p.Root + } + ix, err := corpus.Open(root, false) + if err != nil { + return nil + } + defer ix.Close() + var qvec []float32 + if src := record.Vectors(); src != nil { + qvec = src.Embed(query) // untrimmed, as the app's retrieve + } + live := func(id string) bool { + rest, ok := strings.CutPrefix(id, "shared:") + if !ok { + return true + } + _, num, _ := strings.Cut(rest, ":") + n, err := strconv.ParseInt(num, 10, 64) + if err != nil { + return false + } + e, err := t.st.Get(t.ctx, n) + return err == nil && e != nil + } + ctx, cancel := context.WithTimeout(t.ctx, 6*time.Second) + defer cancel() + res, _ := ix.Retrieve(ctx, query, qvec, limit, live) + return res } // ── memory_get, memory_list, memory_history ────────────────────────────────── @@ -663,9 +726,21 @@ func (t *call) forget(args json.RawMessage) (any, error) { if err != nil { return nil, err } + before, _ := t.st.Get(t.ctx, id) ok, err := t.st.Forget(t.ctx, id, t.g.SessionID, t.s.now()) if err != nil { return nil, err } + if ok && before != nil { + // Its document leaves the index now, not at the next pass. + root := t.g.Cwd + if p, err := project.Discover(t.ctx, t.g.Cwd); err == nil { + root = p.Root + } + if ix, err := corpus.Open(root, false); err == nil { + _ = ix.Delete(t.ctx, fmt.Sprintf("shared:%s:%d", before.Kind, id)) + _ = ix.Close() + } + } return map[string]any{"forgotten": ok, "id": id}, nil } diff --git a/internal/project/state_main_test.go b/internal/project/state_main_test.go new file mode 100644 index 0000000..4ea28e6 --- /dev/null +++ b/internal/project/state_main_test.go @@ -0,0 +1,15 @@ +package project + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/redact/state_main_test.go b/internal/redact/state_main_test.go new file mode 100644 index 0000000..9c2f42f --- /dev/null +++ b/internal/redact/state_main_test.go @@ -0,0 +1,15 @@ +package redact + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/run/gone.go b/internal/run/gone.go new file mode 100644 index 0000000..c5e2b50 --- /dev/null +++ b/internal/run/gone.go @@ -0,0 +1,21 @@ +package run + +import ( + "os" + "syscall" +) + +// deleted reports that an open file was unlinked: its link count is 0. A +// re-created file can reuse the inode number (ext4 does), so comparing +// inodes alone misses a replaced store; the open handle does not. +func deleted(f *os.File) bool { + if f == nil { + return false + } + fi, err := f.Stat() + if err != nil { + return true + } + st, ok := fi.Sys().(*syscall.Stat_t) + return ok && st.Nlink == 0 +} diff --git a/internal/run/host.go b/internal/run/host.go index 4454a1f..9eb7666 100644 --- a/internal/run/host.go +++ b/internal/run/host.go @@ -7,6 +7,7 @@ package run import ( "context" "errors" + "os" "time" gitlink "github.com/tryatlas/atlas-cli/internal/capture/git" @@ -43,12 +44,33 @@ type Host struct { lock *store.WriterLock cancel context.CancelFunc done chan struct{} + opened os.FileInfo // sessions.db as the host opened it; nil when there was none + held *os.File // a handle on that file: unlinked means .atlas/ went away +} + +// Stale reports that the Project's store is no longer the one this host +// opened: .atlas/ was deleted or re-created (atlas init again, or by hand), +// or a store appeared where there was none. A stale host must stop; it +// would otherwise keep writing into a deleted file. +func (h *Host) Stale() bool { + now, err := os.Stat(store.Path(h.Root)) + switch { + case h.opened == nil: + return err == nil + case err != nil || deleted(h.held): + return true + } + return !os.SameFile(h.opened, now) } // StartHost takes the writer lock, opens the capture store, and starts the // listener and the drain. func StartHost(root string, opt HostOptions) (*Host, error) { h := &Host{Root: root, done: make(chan struct{})} + if fi, err := os.Stat(store.Path(root)); err == nil { + h.opened = fi + h.held, _ = os.Open(store.Path(root)) + } lock, err := store.Lock(root) switch { case errors.Is(err, store.ErrLocked) && opt.CaptureOptional: @@ -94,6 +116,9 @@ func StartHost(root string, opt HostOptions) (*Host, error) { func (h *Host) Capturing() bool { return h.Recorder != nil } func (h *Host) release() { + if h.held != nil { + _ = h.held.Close() + } if h.Store != nil { _ = h.Store.Close() } diff --git a/internal/run/import.go b/internal/run/import.go index 3898096..7f5dd4b 100644 --- a/internal/run/import.go +++ b/internal/run/import.go @@ -14,6 +14,7 @@ import ( "github.com/tryatlas/atlas-cli/internal/agents/acp" "github.com/tryatlas/atlas-cli/internal/capture/record" + "github.com/tryatlas/atlas-cli/internal/clistate" ) // Importing sessions run in an agent's own UI (ADR-0012): each installed @@ -24,7 +25,7 @@ import ( // ImportStatePath is the CLI's own record of what was imported: // {"": {"": ""}}. -func ImportStatePath(root string) string { return filepath.Join(root, ".atlas", "cli-import.json") } +func ImportStatePath(root string) string { return clistate.Path(root, "import.json") } type importState map[string]map[string]string diff --git a/internal/run/session_test.go b/internal/run/session_test.go index b136c79..882dfeb 100644 --- a/internal/run/session_test.go +++ b/internal/run/session_test.go @@ -19,6 +19,8 @@ import ( ) func TestMain(m *testing.M) { + state, _ := os.MkdirTemp("", "atlas-state-") // never the user's CLI state + _ = os.Setenv("XDG_STATE_HOME", state) acptest.Main() os.Exit(m.Run()) } diff --git a/internal/run/stale_test.go b/internal/run/stale_test.go new file mode 100644 index 0000000..b00c646 --- /dev/null +++ b/internal/run/stale_test.go @@ -0,0 +1,37 @@ +package run + +import ( + "os" + "path/filepath" + "testing" + + "github.com/tryatlas/atlas-cli/internal/capture/store" +) + +func TestHostStaleWhenTheStoreIsReplaced(t *testing.T) { + root := t.TempDir() + st, err := store.Open(root, true) + if err != nil { + t.Fatal(err) + } + _ = st.Close() + fi, _ := os.Stat(store.Path(root)) + held, _ := os.Open(store.Path(root)) + defer held.Close() + h := &Host{Root: root, opened: fi, held: held} + if h.Stale() { + t.Fatal("same store") + } + _ = os.RemoveAll(filepath.Join(root, ".atlas")) + if !h.Stale() { + t.Fatal("deleted store") + } + st, _ = store.Open(root, true) + _ = st.Close() + if !h.Stale() { + t.Fatal("re-created store") + } + if !(&Host{Root: root}).Stale() { + t.Fatal("a store that appeared after start") + } +} diff --git a/internal/timefmt/state_main_test.go b/internal/timefmt/state_main_test.go new file mode 100644 index 0000000..0eb7904 --- /dev/null +++ b/internal/timefmt/state_main_test.go @@ -0,0 +1,15 @@ +package timefmt + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/tui/card.go b/internal/tui/card.go new file mode 100644 index 0000000..5fc955c --- /dev/null +++ b/internal/tui/card.go @@ -0,0 +1,150 @@ +package tui + +import ( + "fmt" + "io" + "os" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/term" +) + +// Out wraps a terminal writer so colours are cut down to what the terminal +// shows (256 or 16 colours, or none). +func Out(w io.Writer) io.Writer { return colorprofile.NewWriter(w, os.Environ()) } + +// Header is a command's first line: the mark, the name, and where we are. +func Header(w io.Writer, crumb ...string) { + line := Wordmark() + if len(crumb) > 0 { + line += " " + Crumb(crumb...) + } + fmt.Fprintln(w, line) +} + +// Section starts a group of fields: an upper-case label, as the app's +// eyebrows. +func Section(w io.Writer, title string) { + fmt.Fprintln(w) + fmt.Fprintln(w, Upper(title)) +} + +// fieldWidth is the label column. +const fieldWidth = 14 + +// Field is one "label value" line of a section. +func Field(w io.Writer, label, value string) { + fmt.Fprintln(w, " "+Default.Faint.Render(fmt.Sprintf("%-*s", fieldWidth, label))+" "+value) +} + +// State is how a value reads at a glance. +type State int + +// States. +const ( + Neutral State = iota + Good + Warn + Bad + Idle +) + +// Dot is a status dot and the value: ● in the status colour. +func Dot(s State, value string) string { + dot := map[State]lipgloss.Style{Good: Default.Success, Warn: Default.Warning, Bad: Default.Error, Idle: Default.Off}[s] + if s == Neutral { + return " " + Default.Option.Render(value) + } + return dot.Render("●") + " " + value +} + +// Done prints "✓ msg" on a terminal, msg elsewhere. +func Done(w io.Writer, msg string) { + if Interactive() { + fmt.Fprintln(w, Default.Success.Render("✓")+" "+Default.Bold.Render(msg)) + return + } + fmt.Fprintln(w, msg) +} + +// Note prints a secondary line: indented and faint on a terminal. +func Note(w io.Writer, msg string) { + if Interactive() { + fmt.Fprintln(w, " "+Default.Faint.Render(msg)) + return + } + fmt.Fprintln(w, msg) +} + +// Table renders rows under upper-case headers with a rule between; the +// first column is bold. +func Table(headers []string, rows [][]string) string { + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = lipgloss.Width(h) + } + for _, r := range rows { + for i := range min(len(r), len(widths)) { + widths[i] = max(widths[i], lipgloss.Width(r[i])) + } + } + var b strings.Builder + cells := make([]string, len(headers)) + total := 0 + for i, h := range headers { + cells[i] = Upper(Fit(h, widths[i])) + total += widths[i] + 3 + } + b.WriteString(" " + strings.Join(cells, " ") + "\n") + b.WriteString(" " + Rule(total-3) + "\n") + for _, r := range rows { + for i := range cells { + v := "" + if i < len(r) { + v = r[i] + } + if i == 0 { + v = Default.Bold.Render(v) + } + cells[i] = Fit(v, widths[i]) + } + b.WriteString(" " + strings.TrimRight(strings.Join(cells, " "), " ") + "\n") + } + return b.String() +} + +// Width is the terminal's width, 100 when it cannot be read. +func Width() int { + if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 { + return w + } + return 100 +} + +// lineWriter restyles each complete line written through it. +type lineWriter struct { + w io.Writer + fn func(string) string + part string +} + +// Lines returns a writer that passes each line through fn before writing +// it to w; plain output from shared code gets a screen's look this way. +func Lines(w io.Writer, fn func(line string) string) io.Writer { return &lineWriter{w: w, fn: fn} } + +func (lw *lineWriter) Write(p []byte) (int, error) { + lw.part += string(p) + for { + i := strings.IndexByte(lw.part, '\n') + if i < 0 { + return len(p), nil + } + line := lw.part[:i] + lw.part = lw.part[i+1:] + if _, err := io.WriteString(lw.w, lw.fn(line)+"\n"); err != nil { + return 0, err + } + } +} diff --git a/internal/tui/chat/state_main_test.go b/internal/tui/chat/state_main_test.go new file mode 100644 index 0000000..a1964b6 --- /dev/null +++ b/internal/tui/chat/state_main_test.go @@ -0,0 +1,15 @@ +package chat + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/tui/frame.go b/internal/tui/frame.go new file mode 100644 index 0000000..ab29165 --- /dev/null +++ b/internal/tui/frame.go @@ -0,0 +1,166 @@ +package tui + +import ( + "io" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// The frame every screen shares: a breadcrumb over a rule, a key line under +// a rule, titled panels, tags, tree lines and a block progress bar. + +// Crumb is "atlas / settings / agents": the path faint, the last part bright. +func Crumb(parts ...string) string { + if len(parts) == 0 { + return "" + } + out := make([]string, len(parts)) + for i, p := range parts { + if i == len(parts)-1 { + out[i] = Default.CrumbHere.Render(p) + } else { + out[i] = Default.Crumb.Render(p) + } + } + return strings.Join(out, Default.Crumb.Render(" / ")) +} + +// Rule is a horizontal line w cells wide. +func Rule(w int) string { return Default.Rule.Render(strings.Repeat("─", max(w, 1))) } + +// Keys renders key hints, "[ENTER] select · [ESC] back", from key, label pairs. +func Keys(pairs ...string) string { + var out []string + for i := 0; i+1 < len(pairs); i += 2 { + out = append(out, Default.Key.Render("["+pairs[i]+"]")+" "+Default.KeyLabel.Render(pairs[i+1])) + } + return strings.Join(out, Default.KeyLabel.Render(" · ")) +} + +// Badge is a key shown inside a list, "<1>". +func Badge(key string) string { return Default.KeyBadge.Render("<" + key + ">") } + +// Tag is a short label on a coloured ground, " ON ". +func Tag(text string, style lipgloss.Style) string { return style.Render(" " + text + " ") } + +// Bar is a block progress bar of width cells, done of total full. +func Bar(done, total, width int) string { + if total <= 0 || width <= 0 { + return "" + } + full := min(width*done/total, width) + return Default.BarFull.Render(strings.Repeat("▮", full)) + Default.BarEmpty.Render(strings.Repeat("▯", width-full)) +} + +// TreeLine is one answered step: "├ Project atlas-cli", or └ for +// the last one. +func TreeLine(last bool, label, answer string) string { + glyph := "├" + if last { + glyph = "└" + } + return Default.Tree.Render(glyph) + " " + lipgloss.NewStyle().Width(16).Render(label) + " " + answer +} + +// Upper is a small heading: "STEP 2 OF 4". +func Upper(s string) string { return Default.Label.Render(strings.ToUpper(s)) } + +// Fit cuts a styled line to w cells and pads it to exactly w. +func Fit(s string, w int) string { + if w <= 0 { + return "" + } + if lipgloss.Width(s) > w { + s = ansi.Truncate(s, w, "…") + } + return s + strings.Repeat(" ", max(w-lipgloss.Width(s), 0)) +} + +// Panel draws body in a rounded box w×h cells (borders included) with the +// title set into the top edge, "╭─ Agents ──╮". A focused panel is +// coloured. +func Panel(title, body string, w, h int, focused bool) string { + if w < 4 || h < 2 { + return "" + } + edge, ts := Default.PanelEdge, Default.PanelTitle + if focused { + edge, ts = Default.Panel, Default.PanelTitleFocused + } + inner := w - 2 + top := edge.Render("╭" + strings.Repeat("─", inner) + "╮") + if title != "" { + t := ansi.Truncate(title, max(inner-4, 1), "…") + top = edge.Render("╭─ ") + ts.Render(t) + edge.Render(" "+strings.Repeat("─", max(inner-3-lipgloss.Width(t), 0))+"╮") + } + var b strings.Builder + b.WriteString(top + "\n") + lines := strings.Split(strings.TrimRight(body, "\n"), "\n") + for i := range h - 2 { + line := "" + if i < len(lines) { + line = lines[i] + } + b.WriteString(edge.Render("│") + " " + Fit(line, inner-2) + " " + edge.Render("│") + "\n") + } + b.WriteString(edge.Render("╰" + strings.Repeat("─", inner) + "╯")) + return b.String() +} + +// Wrap breaks plain text into lines of at most w cells. +func Wrap(s string, w int) []string { + if w < 8 { + return []string{s} + } + var out []string + for _, para := range strings.Split(s, "\n") { + out = append(out, strings.Split(ansi.Wordwrap(para, w, ""), "\n")...) + } + return out +} + +// indentWidth is where Indent wraps a line. +const indentWidth = 76 + +// indentWriter prefixes each line it writes. +type indentWriter struct { + w io.Writer + prefix string + mid bool +} + +// Indent returns a writer that prefixes every line with the tree's stem, +// "│ ", so output printed between steps stays inside the tree. +func Indent(w io.Writer) io.Writer { + return &indentWriter{w: w, prefix: Default.Tree.Render("│") + " "} +} + +func (iw *indentWriter) Write(p []byte) (int, error) { + var b strings.Builder + parts := strings.SplitAfter(string(p), "\n") + for _, part := range parts { + if part == "" { + continue + } + if !iw.mid { + b.WriteString(iw.prefix) + } + text, nl := strings.CutSuffix(part, "\n") + for i, l := range Wrap(text, indentWidth) { + if i > 0 { + b.WriteString("\n" + iw.prefix + " ") // a wrapped line stays on the stem + } + b.WriteString(Default.Faint.Render(l)) + } + if nl { + b.WriteString("\n") + } + iw.mid = !nl + } + if _, err := io.WriteString(iw.w, b.String()); err != nil { + return 0, err + } + return len(p), nil +} diff --git a/internal/tui/live.go b/internal/tui/live.go new file mode 100644 index 0000000..1405558 --- /dev/null +++ b/internal/tui/live.go @@ -0,0 +1,110 @@ +package tui + +import ( + "fmt" + "os" + "strings" + "time" + + tea "charm.land/bubbletea/v2" +) + +// LiveEvent is one line of a live screen's feed. +type LiveEvent struct { + At time.Time + Who, What string + Bad bool +} + +type liveStop struct{} + +type liveModel struct { + title []string + fields [][2]string + events []LiveEvent + count int + frame int + start time.Time + height int +} + +func (m liveModel) Init() tea.Cmd { return workTicker() } + +func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.height = msg.Height + case workTick: + m.frame++ + return m, workTicker() + case LiveEvent: + m.count++ + m.events = append(m.events, msg) + if len(m.events) > 200 { + m.events = m.events[len(m.events)-200:] + } + case liveStop: + return m, tea.Quit + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + } + } + return m, nil +} + +func (m liveModel) View() tea.View { + var b strings.Builder + b.WriteString(Header2(m.title...) + "\n") + for _, f := range m.fields { + b.WriteString(" " + Default.Faint.Render(fmt.Sprintf("%-*s", fieldWidth, f[0])) + " " + f[1] + "\n") + } + up := time.Since(m.start).Round(time.Second) + b.WriteString("\n" + pulse(m.frame) + " " + Default.Bold.Render("Listening") + " " + + Default.Faint.Render(fmt.Sprintf("%d event(s) · up %s", m.count, up)) + "\n\n") + b.WriteString(Upper("activity") + "\n") + room := 8 + if m.height > 0 { + room = max(m.height-len(m.fields)-10, 3) + } + ev := m.events + if len(ev) > room { + ev = ev[len(ev)-room:] + } + if len(ev) == 0 { + b.WriteString(" " + Default.Faint.Render("waiting for your agents' hooks…") + "\n") + } + w := Width() + for _, e := range ev { + dot := Default.Success.Render("●") + if e.Bad { + dot = Default.Error.Render("●") + } + line := fmt.Sprintf(" %s %s %s %s", Default.Faint.Render(e.At.Format("15:04:05")), dot, + Default.Bold.Render(fmt.Sprintf("%-12s", e.Who)), e.What) + b.WriteString(Fit(line, w-1) + "\n") + } + b.WriteString("\n" + Keys("Q", "stop") + "\n") + return tea.NewView(b.String()) +} + +// Live is a running live screen. +type Live struct{ p *tea.Program } + +// StartLive shows a live screen: a header, fields, a pulsing "Listening" +// line and a feed of events. Wait returns when the user quits (q, esc, +// ctrl+c) or Stop is called. +func StartLive(title []string, fields [][2]string) *Live { + p := tea.NewProgram(liveModel{title: title, fields: fields, start: time.Now()}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin)) + return &Live{p: p} +} + +// Run blocks until the screen ends. +func (l *Live) Run() error { _, err := l.p.Run(); return err } + +// Event adds a line to the feed. +func (l *Live) Event(e LiveEvent) { l.p.Send(e) } + +// Stop ends the screen. +func (l *Live) Stop() { l.p.Send(liveStop{}) } diff --git a/internal/tui/login.go b/internal/tui/login.go new file mode 100644 index 0000000..07f39df --- /dev/null +++ b/internal/tui/login.go @@ -0,0 +1,116 @@ +package tui + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// DevicePrompt is what the user needs to approve a device sign-in. +type DevicePrompt struct { + URL, Code string + Expires time.Time + Opened bool // the browser was opened +} + +type ( + loginPrompt DevicePrompt + loginDone struct { + who string + err error + } +) + +type loginModel struct { + prompt *DevicePrompt + frame int + done bool + who string + err error +} + +func (m loginModel) Init() tea.Cmd { return workTicker() } + +func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case workTick: + if m.done { + return m, nil + } + m.frame++ + return m, workTicker() + case loginPrompt: + p := DevicePrompt(msg) + m.prompt = &p + case loginDone: + m.done, m.who, m.err = true, msg.who, msg.err + return m, tea.Quit + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" || msg.String() == "esc" { + m.done, m.err = true, ErrCancelled + return m, tea.Quit + } + } + return m, nil +} + +// spaced writes a code with room between its characters: "W D J B - M J H T". +func spaced(code string) string { return strings.Join(strings.Split(code, ""), " ") } + +func (m loginModel) View() tea.View { + var b strings.Builder + if m.done { + switch { + case errors.Is(m.err, ErrCancelled): + b.WriteString(Default.Error.Render("✗") + " " + Default.Bold.Render("Sign-in cancelled") + "\n") + case m.err != nil: + b.WriteString(Default.Error.Render("✗") + " " + Default.Bold.Render("Sign-in failed") + " " + Default.Error.Render(m.err.Error()) + "\n") + default: + b.WriteString(Default.Success.Render("✓") + " " + Default.Bold.Render("Signed in as "+m.who) + "\n") + } + return tea.NewView(b.String()) + } + b.WriteString(Header2("sign in") + "\n\n") + if m.prompt == nil { + b.WriteString(pulse(m.frame) + " " + Default.Faint.Render("Asking Atlas for a sign-in code…") + "\n") + return tea.NewView(b.String()) + } + code := Default.Logo.Render(spaced(m.prompt.Code)) + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(Default.Rule.GetForeground()).Padding(1, 4).Render(code) + b.WriteString(Upper("your code") + "\n" + box + "\n\n") + b.WriteString(Upper("open") + "\n" + Default.Info.Render(m.prompt.URL) + "\n") + if m.prompt.Opened { + b.WriteString(Default.Faint.Render("opened in your browser") + "\n") + } + left := time.Until(m.prompt.Expires).Round(time.Second) + b.WriteString("\n" + pulse(m.frame) + " " + Default.Bold.Render("Waiting for approval") + " " + + Default.Faint.Render(fmt.Sprintf("code expires in %s", max(left, 0))) + "\n\n") + b.WriteString(Keys("ESC", "cancel") + "\n") + return tea.NewView(b.String()) +} + +// Header2 is a screen's first line as a string. +func Header2(crumb ...string) string { return Wordmark() + " " + Crumb(crumb...) } + +// DeviceLogin runs a device sign-in on screen: run calls prompt once it has +// the code, then blocks until the user approves; it returns who signed in. +func DeviceLogin(run func(prompt func(DevicePrompt)) (string, error)) error { + p := tea.NewProgram(loginModel{}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin)) + go func() { + who, err := run(func(dp DevicePrompt) { p.Send(loginPrompt(dp)) }) + p.Send(loginDone{who, err}) + }() + final, err := p.Run() + if err != nil { + return err + } + if m := final.(loginModel); m.err != nil { + return ShownError{m.err} + } + return nil +} diff --git a/internal/tui/logo.go b/internal/tui/logo.go new file mode 100644 index 0000000..b4ff4bf --- /dev/null +++ b/internal/tui/logo.go @@ -0,0 +1,80 @@ +package tui + +import ( + "strings" +) + +// The Atlas mark is "///" set in Array Bold (Fontshare), as in the desktop +// app's src/assets/atlas-icon.svg. Array's "/" is a stripe of round dots 4 +// wide and 14 tall, moving one column right every two rows; the logo sets +// three of them 3 dot columns apart. Here every dot column is kept and two +// dot rows share a text row (a terminal cell is about twice as tall as it +// is wide), which keeps the glyph's slope and the logo's proportions. Drawn +// in the logo's cream; animations only switch dots between the cream and a +// dim grey, never through a gradient. + +const ( + glyphRows = 14 // dot rows of Array Bold's "/" + glyphWidth = 4 // dots across one stripe + glyphGap = 3 // dot columns between stripes in the logo + logoCols = 3*glyphWidth + 2*glyphGap + glyphRows/2 - 1 +) + +// Logo is the mark, 7 rows by 24 columns of dots. Stripes light one by one, +// each from the bottom up, as t goes from 0 to 1 (1: all lit). +func Logo(t float64) string { + const rows = glyphRows / 2 + var b strings.Builder + for r := range rows { + off := rows - 1 - r // one column right per text row (two dot rows) + line := []rune(strings.Repeat(" ", logoCols)) + lit := make([]bool, logoCols) + for s := range 3 { + x := off + s*(glyphWidth+glyphGap) + on := (float64(s)+float64(rows-1-r)/float64(rows))/3 < t + for i := range glyphWidth { + line[x+i] = '●' + lit[x+i] = on + } + } + for i, c := range line { + switch { + case c == ' ': + b.WriteByte(' ') + case lit[i]: + b.WriteString(Default.Logo.Render("●")) + default: + b.WriteString(Default.LogoDim.Render("·")) + } + } + b.WriteByte('\n') + } + return strings.TrimRight(b.String(), "\n") +} + +// stripe is one stripe of the working pulse: two block quadrants that read +// as a slanted bar in any font. +const stripe = "▟▛" + +// Wordmark is the name that heads every screen; the logo itself is kept +// for the splash and the init intro, where it has room. +func Wordmark() string { return Default.Bold.Render("Atlas") } + +// pulse is a frame of the working mark: one stripe lit, the one behind it +// half lit, the rest dim. Monochrome: cream, grey, dark grey. +func pulse(frame int) string { + const n = 5 // three stripes, then two dark beats + lead := frame % n + parts := make([]string, 3) + for s := range 3 { + switch lead - s { + case 0: + parts[s] = Default.Logo.Render(stripe) + case 1: + parts[s] = Default.Secondary.Render(stripe) + default: + parts[s] = Default.LogoDim.Render(stripe) + } + } + return strings.Join(parts, " ") +} diff --git a/internal/tui/logo_test.go b/internal/tui/logo_test.go new file mode 100644 index 0000000..b7847b9 --- /dev/null +++ b/internal/tui/logo_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +func TestLogoShapes(t *testing.T) { + if s := ansiRE.ReplaceAllString(Logo(0), ""); strings.Contains(s, "●") { + t.Fatal("nothing lit at 0") + } + // Array Bold's "/" three times: 4 dots wide, 3 apart, one column right + // per row, 7 rows of two dot rows each. + logo := strings.Split(ansiRE.ReplaceAllString(Logo(1), ""), "\n") + if len(logo) != 7 || logo[0] != " ●●●● ●●●● ●●●●" || logo[6] != "●●●● ●●●● ●●●● " { + t.Fatalf("%q", logo) + } + if got := ansiRE.ReplaceAllString(pulse(0), ""); got != "▟▛ ▟▛ ▟▛" { + t.Fatal(got) + } +} + +func TestLightDetection(t *testing.T) { + for _, c := range []struct { + theme, fgbg string + want bool + }{{"", "", false}, {"light", "", true}, {"dark", "0;15", false}, {"", "0;15", true}, {"", "15;0", false}, {"", "0;7", true}} { + if got := light(c.theme, c.fgbg); got != c.want { + t.Errorf("light(%q, %q) = %v", c.theme, c.fgbg, got) + } + } +} + +func TestLoginScreen(t *testing.T) { + var m tea.Model = loginModel{} + if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "Asking Atlas for a sign-in code") { + t.Fatal(s) + } + m, _ = m.Update(loginPrompt{URL: "https://auth.example/device", Code: "WDJB-MJHT", Expires: time.Now().Add(10 * time.Minute)}) + s := ansiRE.ReplaceAllString(m.View().Content, "") + for _, want := range []string{"Atlas", "sign in", "YOUR CODE", "W D J B - M J H T", "https://auth.example/device", "Waiting for approval", "code expires in"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q:\n%s", want, s) + } + } + m, _ = m.Update(loginDone{who: "Ada "}) + if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "✓ Signed in as Ada ") { + t.Fatal(s) + } +} + +func TestWorkScreen(t *testing.T) { + var m tea.Model = workModel{title: "Syncing", start: time.Now()} + m, _ = m.Update(workStep("10 row(s) sent")) + s := ansiRE.ReplaceAllString(m.View().Content, "") + if !strings.Contains(s, "Syncing") || !strings.Contains(s, "└ 10 row(s) sent") || !strings.Contains(s, "▮") || !strings.Contains(s, "▯") { + t.Fatal(s) + } + m, _ = m.Update(workDone{msg: "Sent 10 row(s)."}) + if s := ansiRE.ReplaceAllString(m.View().Content, ""); strings.Contains(s, "▯") { + t.Fatalf("the bar must show full before the result:\n%s", s) + } + m, _ = m.Update(workShown{}) + if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "├ 10 row(s) sent") || !strings.Contains(s, "✓ Sent 10 row(s).") { + t.Fatal(s) + } +} diff --git a/internal/tui/memview/memview.go b/internal/tui/memview/memview.go index dae4f31..d3aa4b3 100644 --- a/internal/tui/memview/memview.go +++ b/internal/tui/memview/memview.go @@ -161,57 +161,72 @@ func clip(s string, w int) string { return string(r[:w-1]) + "…" } -// View draws the list and the selected entry side by side. -func (m *Model) View() tea.View { - if m.width == 0 { - return tea.NewView("") - } - var tabs []string +// tabs are the kinds, the current one on the highlight bar. +func (m *Model) tabs() string { + var out []string for i, k := range Kinds { if i == m.kind { - tabs = append(tabs, th.Selected.Render("["+label(k)+"]")) + out = append(out, th.Highlight.Render(" "+label(k)+" ")) } else { - tabs = append(tabs, th.Faint.Render(label(k))) + out = append(out, th.Faint.Render(" "+label(k)+" ")) } } - head := th.Title.Render("Shared memory · "+m.title) + "\n" + strings.Join(tabs, " ") + return strings.Join(out, " ") +} + +// View draws a breadcrumb and the kind tabs, the entries and the selected +// entry in two panels, and the keys. +func (m *Model) View() tea.View { + if m.width == 0 { + return tea.NewView("") + } + W := m.width + left := tui.Header2("memory", label(Kinds[m.kind])) + right := th.Faint.Render(clip(m.title, W/2)) + head := left + strings.Repeat(" ", max(W-lipgloss.Width(left)-lipgloss.Width(right), 1)) + right + head += "\n" + tui.Rule(W) + "\n" + m.tabs() if m.searching || m.query != "" { - head += "\n" + th.BarKey.Render("search ") + m.query + q := m.query if m.searching { - head += "▏" + q += "▏" } + head += " " + tui.Upper("search") + " " + q } - listW := m.width * 2 / 5 - bodyH := m.height - lipgloss.Height(head) - 2 + bodyH := max(m.height-lipgloss.Height(head)-2, 6) + listW := W * 2 / 5 + rows := bodyH - 2 if m.cursor < m.top { m.top = m.cursor } - if m.cursor >= m.top+bodyH { - m.top = m.cursor - bodyH + 1 + if m.cursor >= m.top+rows { + m.top = m.cursor - rows + 1 } var list []string - for i := m.top; i < len(m.entries) && i < m.top+bodyH; i++ { + for i := m.top; i < len(m.entries) && i < m.top+rows; i++ { e := m.entries[i] - line := fmt.Sprintf("%-12s %s", label(e.Kind), clip(e.Content, listW-16)) + kind := th.Accent.Render(fmt.Sprintf("%-12s", label(e.Kind))) + text := clip(e.Content, listW-20) if e.State == record.StateCandidate { - line = th.Faint.Render(line) + text = th.Faint.Render(text) } if i == m.cursor { - list = append(list, th.Selected.Render("› "+line)) + list = append(list, th.Tree.Render("▸")+kind+th.Highlight.Render(" "+text+" ")) } else { - list = append(list, " "+line) + list = append(list, " "+kind+" "+text) } } if len(m.entries) == 0 { - list = append(list, th.Faint.Render(" nothing here")) + list = append(list, th.Faint.Render("nothing here")) } - left := lipgloss.NewStyle().Width(listW).Height(bodyH).Render(strings.Join(list, "\n")) - right := lipgloss.NewStyle().Width(m.width - listW - 2).Height(bodyH).PaddingLeft(2).Render(m.detail(m.width - listW - 4)) - foot := th.Hint.Render("↑/↓ move · tab kind · / search · h history · q quit") + count := fmt.Sprintf("Entries · %d", len(m.entries)) + panels := lipgloss.JoinHorizontal(lipgloss.Top, + tui.Panel(count, strings.Join(list, "\n"), listW, bodyH, true), " ", + tui.Panel("Entry", m.detail(W-listW-5), W-listW-1, bodyH, false)) + foot := tui.Keys("↑↓", "navigate", "TAB", "kind", "/", "search", "H", "history", "Q", "quit") if m.err != nil { - foot = th.Error.Render(m.err.Error()) + foot = th.Error.Render("✗ " + m.err.Error()) } - v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, head, lipgloss.JoinHorizontal(lipgloss.Top, left, right), foot)) + v := tea.NewView(head + "\n" + panels + "\n" + tui.Rule(W) + "\n" + foot) v.AltScreen = true return v } @@ -223,7 +238,7 @@ func (m *Model) detail(w int) string { } wrap := lipgloss.NewStyle().Width(w) var b strings.Builder - b.WriteString(th.Bold.Render(label(e.Kind)) + " " + th.Faint.Render(fmt.Sprintf("#%d · rev %d · %s", e.ID, e.Rev, strings.TrimSpace(e.State+" "+e.Status))) + "\n\n") + b.WriteString(tui.Tag(strings.ToUpper(label(e.Kind)), th.TagRun) + " " + th.Faint.Render(fmt.Sprintf("#%d · rev %d · %s", e.ID, e.Rev, strings.TrimSpace(e.State+" "+e.Status))) + "\n\n") b.WriteString(wrap.Render(e.Content) + "\n\n") meta := [][2]string{ {"by", e.By()}, {"session", e.Session}, {"key", e.Key}, {"confidence", fmt.Sprintf("%.2f", e.Confidence)}, @@ -231,7 +246,7 @@ func (m *Model) detail(w int) string { } for _, kv := range meta { if kv[1] != "" { - b.WriteString(th.BarKey.Render(fmt.Sprintf("%-11s", kv[0])) + kv[1] + "\n") + b.WriteString(tui.Upper(fmt.Sprintf("%-11s", kv[0])) + kv[1] + "\n") } } for _, c := range e.Evidence { @@ -239,10 +254,10 @@ func (m *Model) detail(w int) string { if c.Symbol != nil { ev += " " + *c.Symbol } - b.WriteString(th.Location.Render("evidence "+ev) + "\n") + b.WriteString(tui.Upper("evidence ") + th.Location.Render(ev) + "\n") } if m.showHist { - b.WriteString("\n" + th.Bold.Render("History") + "\n") + b.WriteString("\n" + tui.Upper("history") + "\n") for _, r := range m.history { b.WriteString(th.Faint.Render(fmt.Sprintf("rev %d · %s · %s · %s", r.Rev, r.Op, r.By(), when(r.At))) + "\n") b.WriteString(wrap.Render(" "+clip(r.Content, w*3)) + "\n") diff --git a/internal/tui/memview/memview_test.go b/internal/tui/memview/memview_test.go index c5e9e05..e156558 100644 --- a/internal/tui/memview/memview_test.go +++ b/internal/tui/memview/memview_test.go @@ -51,13 +51,13 @@ func TestBrowser(t *testing.T) { m := New(context.Background(), f, "/repo") _, _ = m.Update(tea.WindowSizeMsg{Width: 140, Height: 30}) s := ansi.ReplaceAllString(m.View().Content, "") - for _, want := range []string{"Shared memory · /repo", "[all]", "decision Use Postgres", "db/ledger.go:3-9", "claude-acp"} { + for _, want := range []string{"Atlas memory / all", "/repo", "Entries · 2", "decision Use Postgres", "db/ledger.go:3-9", "claude-acp", "[TAB] kind"} { if !strings.Contains(s, want) { t.Errorf("missing %q\n%s", want, s) } } key(m, "h", 'h') - if s := ansi.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "rev 1 · insert · codex-acp") || !strings.Contains(s, "Use MySQL") { + if s := ansi.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "HISTORY") || !strings.Contains(s, "rev 1 · insert · codex-acp") || !strings.Contains(s, "Use MySQL") { t.Fatal(s) } _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) diff --git a/internal/tui/memview/state_main_test.go b/internal/tui/memview/state_main_test.go new file mode 100644 index 0000000..688e40b --- /dev/null +++ b/internal/tui/memview/state_main_test.go @@ -0,0 +1,15 @@ +package memview + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/tui/settings/settings.go b/internal/tui/settings/settings.go new file mode 100644 index 0000000..4861f2e --- /dev/null +++ b/internal/tui/settings/settings.go @@ -0,0 +1,466 @@ +// Package settings is the settings screen (atlas settings): account, +// Project gates, agent integrations and background sync, in one place. The +// screen only draws and routes keys; a Backend reads and changes the state. +package settings + +import ( + "context" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/tryatlas/atlas-cli/internal/tui" +) + +var th = tui.Default + +// Kind is how a row behaves. +type Kind int + +// Row kinds. +const ( + Info Kind = iota // a value to read + Toggle // on or off; space flips it + Action // enter runs it +) + +// Item is one row of a section. +type Item struct { + Key string // what Apply receives + Label string + Kind Kind + On bool // Toggle: the current state + Value string // Info: the value; Toggle and Action: a short note after the state + Detail string // shown under the list when the row is selected + // Disabled says why the row cannot change now; empty when it can. + Disabled string + // Confirm, on an action, is asked first: the action runs on a second + // enter. + Confirm string +} + +// Section is one entry of the sidebar. +type Section struct { + Title string + Icon string // one cell, before the title + Items []Item +} + +// Backend reads and changes the settings. +type Backend interface { + Load(ctx context.Context) ([]Section, error) + // Apply flips a toggle to on, or runs an action (on is then true). The + // message is shown in the status line. + Apply(ctx context.Context, key string, on bool) (string, error) +} + +type loadedMsg struct { + sections []Section + err error +} + +type appliedMsg struct { + msg string + err error +} + +// Progresser is a Backend whose long changes report how far they are (a +// model download); the row shows a bar while one runs. +type Progresser interface { + ApplyProgress(ctx context.Context, key string, on bool, report func(frac float64, note string)) (string, error) +} + +type progressMsg struct { + frac float64 + note string + ch chan tea.Msg +} + +// listen waits for the next message of a running change. +func listen(ch chan tea.Msg) tea.Cmd { + return func() tea.Msg { return <-ch } +} + +type tickMsg struct{} + +var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// Model is the screen. +type Model struct { + ctx context.Context + be Backend + title string + sections []Section + sec int + row int + focus int // 0: sidebar, 1: rows + busy string + confirm string // the action key waiting for its second enter + frame int + prog float64 // the running change's progress, -1 when unknown + progNote string + status string + err error + width int + height int +} + +// New opens the screen; title names the Project (or the folder). +func New(ctx context.Context, be Backend, title string) *Model { + m := &Model{ctx: ctx, be: be, title: title, focus: 1} + m.sections, m.err = be.Load(ctx) + return m +} + +// Init does nothing. +func (m *Model) Init() tea.Cmd { return nil } + +func (m *Model) items() []Item { + if m.sec < len(m.sections) { + return m.sections[m.sec].Items + } + return nil +} + +func (m *Model) current() *Item { + if it := m.items(); m.row < len(it) { + return &it[m.row] + } + return nil +} + +func (m *Model) reload() tea.Cmd { + return func() tea.Msg { + s, err := m.be.Load(m.ctx) + return loadedMsg{s, err} + } +} + +func tick() tea.Cmd { + return tea.Tick(90*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) +} + +// apply runs the change off the UI loop: starting a listener takes seconds. +func (m *Model) apply(it *Item, on bool) tea.Cmd { + if it.Disabled != "" { + m.status, m.err = it.Disabled, nil + return nil + } + key := it.Key + m.busy, m.status, m.err = it.Label, "", nil + m.prog, m.progNote = -1, "" + if pb, ok := m.be.(Progresser); ok { + ch := make(chan tea.Msg, 64) + go func() { + msg, err := pb.ApplyProgress(m.ctx, key, on, func(f float64, note string) { + select { + case ch <- progressMsg{frac: f, note: note, ch: ch}: + default: // the screen is behind: drop a frame, not the download + } + }) + ch <- appliedMsg{msg, err} + }() + return tea.Batch(tick(), listen(ch)) + } + return tea.Batch(tick(), func() tea.Msg { + msg, err := m.be.Apply(m.ctx, key, on) + return appliedMsg{msg, err} + }) +} + +// Update handles keys and results. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = max(msg.Width, 64), max(msg.Height, 16) + case tickMsg: + if m.busy != "" { + m.frame++ + return m, tick() + } + case progressMsg: + if msg.frac >= 0 { + m.prog = msg.frac + } + if msg.note != "" || msg.frac >= 0 { + m.progNote = msg.note + } + return m, listen(msg.ch) + case appliedMsg: + m.busy, m.status, m.err = "", msg.msg, msg.err + return m, m.reload() + case loadedMsg: + if msg.err != nil { + m.err = msg.err + } else { + m.sections = msg.sections + } + m.sec = min(m.sec, max(len(m.sections)-1, 0)) + m.row = min(m.row, max(len(m.items())-1, 0)) + case tea.KeyPressMsg: + return m.key(msg.String()) + } + return m, nil +} + +func (m *Model) key(k string) (tea.Model, tea.Cmd) { + switch k { + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + if m.busy != "" { + return m, nil // one change at a time + } + switch k { + case "r": + m.status, m.err = "Refreshed.", nil + return m, m.reload() + case "tab": + m.focus = 1 - m.focus + case "left", "h": + m.focus = 0 + case "right", "l": + if len(m.items()) > 0 { + m.focus = 1 + } + case "up", "k": + m.move(-1) + m.confirm = "" + case "down", "j": + m.move(1) + m.confirm = "" + case "space", "enter", "x": + if m.focus == 0 { + m.focus = 1 + return m, nil + } + if it := m.current(); it != nil { + switch { + case it.Kind == Toggle: + return m, m.apply(it, !it.On) + case it.Kind == Action && k != "space": + if it.Confirm != "" && m.confirm != it.Key && it.Disabled == "" { + m.confirm, m.status, m.err = it.Key, it.Confirm+" Press enter again to confirm.", nil + return m, nil + } + m.confirm = "" + return m, m.apply(it, true) + } + } + default: + // A digit jumps to that section. + if len(k) == 1 && k[0] >= '1' && k[0] <= '9' && int(k[0]-'1') < len(m.sections) { + m.sec, m.row, m.focus = int(k[0]-'1'), 0, 1 + } + } + return m, nil +} + +func (m *Model) move(d int) { + if m.focus == 0 { + if n := len(m.sections); n > 0 { + m.sec = (m.sec + d + n) % n + m.row = 0 + } + return + } + if n := len(m.items()); n > 0 { + m.row = (m.row + d + n) % n + } +} + +// ── View ───────────────────────────────────────────────────────────────────── + +// state is a row's value: on in green, off dim, run in gold, information +// in green, as the tree's answers. +func (m *Model) state(it Item) string { + if m.busy != "" && m.busy == it.Label && it.Kind != Info { + if m.prog >= 0 { + s := tui.Bar(int(m.prog*1000), 1000, 16) + " " + th.Bold.Render(fmt.Sprintf("%3.0f%%", m.prog*100)) + if m.progNote != "" { + s += " " + th.Faint.Render(m.progNote) + } + return s + } + return th.Badge.Render(spinner[m.frame%len(spinner)] + " applying…") + } + note := "" + if it.Value != "" && it.Kind != Info { + note = " " + th.Faint.Render(it.Value) + } + switch it.Kind { + case Toggle: + if it.On { + return th.Answer.Render("on") + note + } + return th.Off.Render("off") + note + case Action: + return th.Tree.Render("run ↵") + note + } + return th.Answer.Render(it.Value) +} + +// glyph is a tree line's stem: ├, or └ for the last line. +func glyph(last bool) string { + if last { + return th.Tree.Render("└") + } + return th.Tree.Render("├") +} + +func (m *Model) navBody(w int) string { + var b strings.Builder + b.WriteString("\n") + for i, s := range m.sections { + title := fmt.Sprintf("%-*s", max(w-4, 1), s.Title) + last := i == len(m.sections)-1 + switch { + case i == m.sec && m.focus == 0: + b.WriteString(th.Tree.Render("▸") + " " + th.Highlight.Render(" "+title)) + case i == m.sec: + b.WriteString(glyph(last) + " " + th.Bold.Render(title)) + default: + b.WriteString(glyph(last) + " " + th.Faint.Render(title)) + } + b.WriteString("\n") + } + return b.String() +} + +func (m *Model) rowsBody(w int) string { + items := m.items() + lw := 0 + for _, it := range items { + lw = max(lw, lipgloss.Width(it.Label)) + } + lw = min(lw, w/2) + var b strings.Builder + b.WriteString("\n") + for i, it := range items { + label := tui.Fit(it.Label, lw) + st := m.state(it) + if it.Disabled != "" && it.Kind != Info { + st = th.Faint.Render(ansi.Strip(st)) + } + if i == m.row && m.focus == 1 { + b.WriteString(th.Tree.Render("▸") + " " + th.Highlight.Render(" "+label+" ") + " " + st + "\n") + } else { + b.WriteString(glyph(i == len(items)-1) + " " + th.Option.Render(label) + " " + st + "\n") + } + } + if len(items) == 0 { + b.WriteString(th.Faint.Render("nothing here")) + } + return b.String() +} + +func (m *Model) detailBody(w int) string { + it := m.current() + if it == nil { + return "" + } + var b strings.Builder + b.WriteString("\n" + th.Bold.Render(it.Label) + "\n\n") + switch it.Kind { + case Info: + b.WriteString(tui.Upper("value") + "\n") + for _, l := range tui.Wrap(it.Value, w) { + b.WriteString(th.Answer.Render(l) + "\n") + } + case Toggle: + b.WriteString(tui.Upper("state") + "\n" + m.state(Item{Kind: Toggle, On: it.On}) + "\n") + if it.Value != "" { + b.WriteString("\n" + tui.Upper("note") + "\n") + for _, l := range tui.Wrap(it.Value, w) { + b.WriteString(l + "\n") + } + } + case Action: + b.WriteString(tui.Keys("ENTER", "run this") + "\n") + } + if it.Detail != "" { + b.WriteString("\n") + for _, l := range tui.Wrap(it.Detail, w) { + b.WriteString(th.Faint.Render(l) + "\n") + } + } + if it.Disabled != "" { + b.WriteString("\n" + tui.Tag("!", th.TagWarn) + " ") + for i, l := range tui.Wrap(it.Disabled, w-4) { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(th.Badge.Render(l) + "\n") + } + } + return b.String() +} + +// View draws a breadcrumb over a rule, the sections, rows and details +// panels, and the keys under a rule with the last result. +func (m *Model) View() tea.View { + if m.width == 0 { + return tea.NewView("") + } + W, H := m.width, m.height + here := "settings" + if m.sec < len(m.sections) { + here = strings.ToLower(m.sections[m.sec].Title) + } + left := tui.Header2("settings", here) + right := th.Faint.Render(ansi.Truncate(m.title, W/2, "…")) + head := left + strings.Repeat(" ", max(W-lipgloss.Width(left)-lipgloss.Width(right), 1)) + right + + bodyH := max(H-4, 6) + navW := 12 + for _, s := range m.sections { + navW = max(navW, lipgloss.Width(s.Title)+10) + } + rest := W - navW - 1 + var panels string + nav := tui.Panel("Sections", m.navBody(navW-4), navW, bodyH, m.focus == 0) + title := "" + if m.sec < len(m.sections) { + title = m.sections[m.sec].Title + } + if W >= 110 { + rowsW := rest * 11 / 20 + detW := rest - rowsW - 1 + rows := tui.Panel(title, m.rowsBody(rowsW-4), rowsW, bodyH, m.focus == 1) + det := tui.Panel("Details", m.detailBody(detW-4), detW, bodyH, false) + panels = lipgloss.JoinHorizontal(lipgloss.Top, nav, " ", rows, " ", det) + } else { + body := m.rowsBody(rest - 4) + if m.focus == 1 { + body += "\n" + th.Rule.Render(strings.Repeat("─", rest-4)) + "\n" + m.detailBody(rest-4) + } + panels = lipgloss.JoinHorizontal(lipgloss.Top, nav, " ", tui.Panel(title, body, rest, bodyH, m.focus == 1)) + } + + keys := tui.Keys("↑↓", "navigate", "→", "open", "1-9", "section", "Q", "quit") + if m.focus == 1 { + keys = tui.Keys("↑↓", "navigate", "SPACE", "toggle", "ENTER", "run", "←", "sections", "R", "refresh", "Q", "quit") + } + status := "" + switch { + case m.err != nil: + status = th.Error.Render("✗ " + m.err.Error()) + case m.status != "": + status = th.Hex.Render("⬢ ") + m.status + } + if status != "" { + room := W - lipgloss.Width(keys) - 3 + if room < 12 { + keys, room = "", W + } + status = tui.Fit(status, room) + keys += " " + status + } + v := tea.NewView(head + "\n" + tui.Rule(W) + "\n" + panels + "\n" + tui.Rule(W) + "\n" + keys) + v.AltScreen = true + return v +} diff --git a/internal/tui/settings/settings_test.go b/internal/tui/settings/settings_test.go new file mode 100644 index 0000000..8c917b0 --- /dev/null +++ b/internal/tui/settings/settings_test.go @@ -0,0 +1,142 @@ +package settings + +import ( + "context" + "regexp" + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +type fake struct { + on map[string]bool + applied []string +} + +func (f *fake) Load(context.Context) ([]Section, error) { + return []Section{ + {Title: "Account", Items: []Item{{Label: "Signed in as", Value: "Ada "}}}, + {Title: "Agents", Items: []Item{ + {Key: "agent:codex", Label: "Codex", Kind: Toggle, On: f.on["agent:codex"], Detail: "Capture Codex sessions"}, + {Key: "agent:pi", Label: "Pi", Kind: Toggle, Disabled: "Run atlas init in this folder first."}, + {Key: "agents:all", Label: "Add all detected agents", Kind: Action}, + }}, + }, nil +} + +func (f *fake) Apply(_ context.Context, key string, on bool) (string, error) { + f.applied = append(f.applied, key) + f.on[key] = on + return "done " + key, nil +} + +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`) + +// run feeds a command's message back, as the program would. +func run(m *Model, cmd tea.Cmd) { + for cmd != nil { + msg := cmd() + if b, ok := msg.(tea.BatchMsg); ok { + for _, c := range b { + if r := c(); r != nil { + if _, ok := r.(tickMsg); !ok { + _, cmd = m.Update(r) + run(m, cmd) + } + } + } + return + } + _, cmd = m.Update(msg) + } +} + +func key(m *Model, code rune, text string) { + _, cmd := m.Update(tea.KeyPressMsg{Code: code, Text: text}) + run(m, cmd) +} + +func TestSettingsScreen(t *testing.T) { + f := &fake{on: map[string]bool{}} + m := New(context.Background(), f, "/repo") + _, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + s := ansiRE.ReplaceAllString(m.View().Content, "") + for _, want := range []string{"Atlas settings / account", "/repo", "├ Account", "└ Agents", "╭─ Sections", "╭─ Details", "Ada "} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q\n%s", want, s) + } + } + key(m, '2', "2") + s = ansiRE.ReplaceAllString(m.View().Content, "") + if !strings.Contains(s, "▸ Codex ") || !strings.Contains(s, " off") || !strings.Contains(s, "Capture Codex sessions") || !strings.Contains(s, "Atlas settings / agents") { + t.Fatal(s) + } + key(m, tea.KeySpace, " ") // toggle Codex on + if len(f.applied) != 1 || f.applied[0] != "agent:codex" || !f.on["agent:codex"] { + t.Fatalf("%v", f.applied) + } + s = ansiRE.ReplaceAllString(m.View().Content, "") + if !strings.Contains(s, " on") || !strings.Contains(s, "⬢ done agent:codex") { + t.Fatal(s) + } + key(m, tea.KeyDown, "") + key(m, tea.KeySpace, " ") // disabled: explains, applies nothing + if len(f.applied) != 1 || !strings.Contains(ansiRE.ReplaceAllString(m.View().Content, ""), "atlas init") { + t.Fatalf("%v", f.applied) + } + key(m, tea.KeyDown, "") + key(m, tea.KeySpace, " ") // space does not run an action + key(m, tea.KeyEnter, "") + if len(f.applied) != 2 || f.applied[1] != "agents:all" { + t.Fatalf("%v", f.applied) + } + key(m, tea.KeyLeft, "") + key(m, tea.KeyUp, "") + if m.sec != 0 || m.focus != 0 { + t.Fatalf("sec %d focus %d", m.sec, m.focus) + } +} + +type progressFake struct{ fake } + +func (p *progressFake) ApplyProgress(_ context.Context, key string, on bool, report func(float64, string)) (string, error) { + report(0.5, "40 of 87 MB") + return p.Apply(context.Background(), key, on) +} + +func TestProgressAndConfirm(t *testing.T) { + f := &progressFake{fake{on: map[string]bool{}}} + m := New(context.Background(), f, "/repo") + _, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m.sections = []Section{{Title: "Model", Items: []Item{ + {Key: "model:download", Label: "Download model", Kind: Action}, + {Key: "model:remove", Label: "Remove model", Kind: Action, Confirm: "This deletes the files."}, + }}} + m.sec, m.row, m.focus = 0, 0, 1 + // The running change shows its progress in the row. + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil || m.busy != "Download model" { + t.Fatal("download did not start") + } + _, _ = m.Update(progressMsg{frac: 0.5, note: "40 of 87 MB", ch: make(chan tea.Msg, 1)}) + if s := ansiRE.ReplaceAllString(m.View().Content, ""); !strings.Contains(s, "50%") || !strings.Contains(s, "40 of 87 MB") { + t.Fatal(s) + } + _, _ = m.Update(appliedMsg{msg: "Model downloaded."}) + m.sections = []Section{{Title: "Model", Items: []Item{ + {Key: "model:download", Label: "Download model", Kind: Action}, + {Key: "model:remove", Label: "Remove model", Kind: Action, Confirm: "This deletes the files."}, + }}} + m.row = 1 + // A confirmed action asks first. + key(m, tea.KeyEnter, "") + if slices.Contains(f.applied, "model:remove") || !strings.Contains(m.status, "Press enter again") { + t.Fatalf("%v %q", f.applied, m.status) + } + _, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil || m.busy != "Remove model" { + t.Fatal("second enter must run it") + } +} diff --git a/internal/tui/settings/state_main_test.go b/internal/tui/settings/state_main_test.go new file mode 100644 index 0000000..7fbb0aa --- /dev/null +++ b/internal/tui/settings/state_main_test.go @@ -0,0 +1,15 @@ +package settings + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/tui/splash.go b/internal/tui/splash.go new file mode 100644 index 0000000..180af64 --- /dev/null +++ b/internal/tui/splash.go @@ -0,0 +1,87 @@ +package tui + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type introTick struct{} + +// intro plays a render function from t=0 to t=1, then leaves the last frame. +type intro struct { + render func(t float64) string + frame int + frames int +} + +func (m intro) Init() tea.Cmd { return introNext() } + +func introNext() tea.Cmd { + return tea.Tick(28*time.Millisecond, func(time.Time) tea.Msg { return introTick{} }) +} + +func (m intro) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case introTick: + m.frame++ + if m.frame >= m.frames { + return m, tea.Quit + } + return m, introNext() + case tea.KeyPressMsg: + m.frame = m.frames // any key skips + return m, tea.Quit + } + return m, nil +} + +func (m intro) View() tea.View { + t := min(float64(m.frame)/float64(max(m.frames-1, 1)), 1) + return tea.NewView(m.render(t) + "\n") +} + +// Animate plays render from 0 to 1 (about half a second) on a terminal and +// leaves the final frame; elsewhere it prints only the final frame. +func Animate(w io.Writer, render func(t float64) string) { + if !Interactive() || os.Getenv("ATLAS_NO_ANIMATION") != "" { + fmt.Fprintln(w, render(1)) + return + } + _, _ = tea.NewProgram(intro{render: render, frames: 20}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin)).Run() +} + +// Command is one line of the splash's command list. +type Command struct{ Name, Short string } + +// Splash is `atlas` with no command: the mark lighting up stripe by stripe, +// the name and version beside it, then the commands by group. +func Splash(w io.Writer, version string, groups map[string][]Command, order []string) { + Animate(w, func(t float64) string { + logo := Logo(t) + side := Default.Bold.Render("Atlas") + " " + Default.Faint.Render(version) + "\n" + + Default.Faint.Render("Shared memory and session capture") + "\n" + Default.Faint.Render("for coding agents.") + return "\n" + lipgloss.JoinHorizontal(lipgloss.Center, " "+strings.ReplaceAll(logo, "\n", "\n "), " ", side) + "\n" + }) + nameW := 0 + for _, g := range order { + for _, c := range groups[g] { + nameW = max(nameW, len(c.Name)) + } + } + for _, g := range order { + fmt.Fprintln(w) + fmt.Fprintln(w, " "+Upper(g)) + for _, c := range groups[g] { + fmt.Fprintln(w, " "+Default.Bold.Render(fmt.Sprintf("atlas %-*s", nameW, c.Name))+" "+Default.Faint.Render(Fit(c.Short, Width()-nameW-12))) + } + } + fmt.Fprintln(w) + fmt.Fprintln(w, " "+Keys("atlas --help", "details", "atlas init", "start here")) + fmt.Fprintln(w) +} diff --git a/internal/tui/state_main_test.go b/internal/tui/state_main_test.go new file mode 100644 index 0000000..17d7b81 --- /dev/null +++ b/internal/tui/state_main_test.go @@ -0,0 +1,15 @@ +package tui + +import ( + "os" + "testing" +) + +// TestMain keeps the CLI state directory (clistate) out of the real home. +func TestMain(m *testing.M) { + dir, _ := os.MkdirTemp("", "atlas-state-") + _ = os.Setenv("XDG_STATE_HOME", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 0456bdf..c664f3d 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -1,9 +1,20 @@ package tui -import "charm.land/lipgloss/v2" +import ( + "image/color" + "os" + "strings" + + "charm.land/lipgloss/v2" +) // Theme is every colour and style the CLI draws with. No other file names a // colour: screens take their styles from here. +// +// The palette is the Atlas desktop app's (crates/atlas-theme/themes/ +// atlas.toml): monochrome first, black and white with greys between; the +// action colour is an inversion of the page, not a hue; colour is kept for +// status (success, warning, error, info). No gradients. The logo is cream. type Theme struct { Title, Cursor, Hint, Faint, Bold lipgloss.Style @@ -20,60 +31,176 @@ type Theme struct { Overlay, OverlayTitle lipgloss.Style Selected, Option, Input lipgloss.Style Border lipgloss.Style + + // Wizard and settings. + Brand, Accent, Check, Off, Badge lipgloss.Style + Pane, PaneFocused, Sidebar lipgloss.Style + + // Frame: breadcrumb, rules, key hints, panels, tags (frame.go). + Crumb, CrumbHere, Rule lipgloss.Style + Key, KeyLabel, KeyBadge lipgloss.Style + Tree, Answer, Label lipgloss.Style + Highlight, PanelEdge, Panel lipgloss.Style + PanelTitle, PanelTitleFocused lipgloss.Style + TagOn, TagOff, TagRun, TagWarn lipgloss.Style + BarFull, BarEmpty, Hex, HexIdle lipgloss.Style + + // Logo and status. + Logo, LogoDim lipgloss.Style + Success, Warning, Info, Secondary lipgloss.Style } -// ANSI palette indices, so the user's terminal theme decides the shades. -var ( - cBlue = lipgloss.Color("12") - cCyan = lipgloss.Color("6") - cGreen = lipgloss.Color("2") - cRed = lipgloss.Color("1") - cYellow = lipgloss.Color("3") - cMagenta = lipgloss.Color("5") - cGrey = lipgloss.Color("8") -) +// Palette is one theme's colours. +type Palette struct { + Bg, Panel, Fill, Popover color.Color + Border, BorderStrong color.Color + Fg, Text, Secondary, Muted, Dim color.Color + Logo color.Color + Success, Warning, Error, Info color.Color + SuccessBg color.Color + Gold, Selection color.Color +} + +// Dark is atlas.toml [dark]. +var Dark = Palette{ + Bg: lipgloss.Color("#000000"), Panel: lipgloss.Color("#0a0a0a"), Fill: lipgloss.Color("#171717"), Popover: lipgloss.Color("#1c1c1c"), + Border: lipgloss.Color("#1e1e1e"), BorderStrong: lipgloss.Color("#3d3d3d"), + Fg: lipgloss.Color("#ffffff"), Text: lipgloss.Color("#d4d4d4"), Secondary: lipgloss.Color("#aaaaaa"), + Muted: lipgloss.Color("#8f8f8f"), Dim: lipgloss.Color("#666666"), + Logo: lipgloss.Color("#FFFFE2"), + Success: lipgloss.Color("#9ecf8a"), Warning: lipgloss.Color("#ffff00"), Error: lipgloss.Color("#f44747"), Info: lipgloss.Color("#6796e6"), + SuccessBg: lipgloss.Color("#0d2211"), + Gold: lipgloss.Color("#e5c07b"), // the app's terminal yellow (keys.toml) + Selection: lipgloss.Color("#303030"), // editor.selection.background +} + +// Light is atlas.toml [light]. +var Light = Palette{ + Bg: lipgloss.Color("#ffffff"), Panel: lipgloss.Color("#fafafa"), Fill: lipgloss.Color("#f2f2f2"), Popover: lipgloss.Color("#f0f0f0"), + Border: lipgloss.Color("#e0e0e0"), BorderStrong: lipgloss.Color("#bdbdbd"), + Fg: lipgloss.Color("#000000"), Text: lipgloss.Color("#2b2b2b"), Secondary: lipgloss.Color("#4f4f4f"), + Muted: lipgloss.Color("#737373"), Dim: lipgloss.Color("#8a8a8a"), + Logo: lipgloss.Color("#000000"), + Success: lipgloss.Color("#3f7f2f"), Warning: lipgloss.Color("#8f7a00"), Error: lipgloss.Color("#c62828"), Info: lipgloss.Color("#1f5fb0"), + SuccessBg: lipgloss.Color("#e3f1dc"), + Gold: lipgloss.Color("#8f7a00"), + Selection: lipgloss.Color("#d0d0d0"), +} + +// NewTheme builds the styles from a palette. +func NewTheme(p Palette) Theme { + s := func() lipgloss.Style { return lipgloss.NewStyle() } + fg := func(c color.Color) lipgloss.Style { return s().Foreground(c) } + return Theme{ + Title: fg(p.Fg).Bold(true), + Cursor: fg(p.Fg).Bold(true), + Hint: fg(p.Dim), + Faint: fg(p.Muted), + Bold: fg(p.Fg).Bold(true), + + User: fg(p.Fg).Bold(true), + Assistant: fg(p.Text), + Thinking: fg(p.Muted).Italic(true), + Notice: fg(p.Muted), + Error: fg(p.Error).Bold(true), + + ToolTitle: fg(p.Fg).Bold(true), + ToolMeta: fg(p.Muted), + Location: fg(p.Info), + + StatusPending: fg(p.Dim), + StatusRunning: fg(p.Warning), + StatusDone: fg(p.Success), + StatusFailed: fg(p.Error), + + DiffAdd: fg(p.Success), + DiffDel: fg(p.Error), + DiffHunk: fg(p.Info), + Terminal: fg(p.Muted), + + PlanDone: fg(p.Success), + PlanActive: fg(p.Fg).Bold(true), + PlanPending: fg(p.Dim), + + Bar: fg(p.Muted), + BarKey: fg(p.Secondary), + BarValue: fg(p.Fg).Bold(true), + Usage: fg(p.Muted), + + Overlay: s().Border(lipgloss.RoundedBorder()).BorderForeground(p.BorderStrong).Padding(0, 1), + OverlayTitle: fg(p.Fg).Bold(true), + Selected: fg(p.Fg).Bold(true), + Option: fg(p.Text), + Input: s().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(p.BorderStrong), + Border: fg(p.BorderStrong), + + Brand: fg(p.Logo).Bold(true), + Accent: fg(p.Secondary), + Check: fg(p.Success).Bold(true), + Off: fg(p.Dim), + Badge: fg(p.Secondary), + Pane: s().Border(lipgloss.RoundedBorder()).BorderForeground(p.BorderStrong).Padding(0, 1), + PaneFocused: s().Border(lipgloss.RoundedBorder()).BorderForeground(p.Fg).Padding(0, 1), + Sidebar: s().Padding(0, 1), + + Crumb: fg(p.Dim), + CrumbHere: fg(p.Fg).Bold(true), + Rule: fg(p.BorderStrong), + Key: fg(p.Fg).Bold(true), + KeyLabel: fg(p.Dim), + KeyBadge: fg(p.Secondary).Background(p.Fill), + Tree: fg(p.Gold), + Answer: fg(p.Success), + Label: fg(p.Dim).Bold(true), + Highlight: fg(p.Fg).Background(p.Selection).Bold(true), + + PanelEdge: fg(p.BorderStrong), + Panel: fg(p.Fg), + PanelTitle: fg(p.Dim), + PanelTitleFocused: fg(p.Fg).Bold(true), + + TagOn: fg(p.Success).Background(p.SuccessBg).Bold(true), + TagOff: fg(p.Dim).Background(p.Fill), + TagRun: fg(p.Fg).Background(p.Popover).Bold(true), + TagWarn: fg(p.Warning).Background(p.Fill).Bold(true), + + BarFull: fg(p.Success), + BarEmpty: fg(p.BorderStrong), + Hex: fg(p.Success), + HexIdle: fg(p.Dim), + + Logo: fg(p.Logo).Bold(true), + LogoDim: fg(p.BorderStrong), + Success: fg(p.Success), + Warning: fg(p.Warning), + Info: fg(p.Info), + Secondary: fg(p.Secondary), + } +} + +// Default is the theme in use; screens hold the pointer, so Detect can +// switch it to the light palette before anything draws. +var Default = func() *Theme { t := NewTheme(Dark); return &t }() + +// Detect picks the light or dark palette: ATLAS_THEME=light|dark, else the +// terminal's COLORFGBG ("15;0": light text on black), else dark, the app's +// default. The terminal is not queried: one that does not answer would hang. +func Detect() { + if light(os.Getenv("ATLAS_THEME"), os.Getenv("COLORFGBG")) { + *Default = NewTheme(Light) + } +} -// Default is the theme. -var Default = Theme{ - Title: lipgloss.NewStyle().Bold(true), - Cursor: lipgloss.NewStyle().Foreground(cBlue).Bold(true), - Hint: lipgloss.NewStyle().Faint(true), - Faint: lipgloss.NewStyle().Faint(true), - Bold: lipgloss.NewStyle().Bold(true), - - User: lipgloss.NewStyle().Foreground(cBlue).Bold(true), - Assistant: lipgloss.NewStyle(), - Thinking: lipgloss.NewStyle().Faint(true).Italic(true), - Notice: lipgloss.NewStyle().Foreground(cGrey), - Error: lipgloss.NewStyle().Foreground(cRed).Bold(true), - - ToolTitle: lipgloss.NewStyle().Bold(true), - ToolMeta: lipgloss.NewStyle().Foreground(cGrey), - Location: lipgloss.NewStyle().Foreground(cCyan), - - StatusPending: lipgloss.NewStyle().Foreground(cGrey), - StatusRunning: lipgloss.NewStyle().Foreground(cYellow), - StatusDone: lipgloss.NewStyle().Foreground(cGreen), - StatusFailed: lipgloss.NewStyle().Foreground(cRed), - - DiffAdd: lipgloss.NewStyle().Foreground(cGreen), - DiffDel: lipgloss.NewStyle().Foreground(cRed), - DiffHunk: lipgloss.NewStyle().Foreground(cCyan), - Terminal: lipgloss.NewStyle().Foreground(cGrey), - - PlanDone: lipgloss.NewStyle().Foreground(cGreen), - PlanActive: lipgloss.NewStyle().Foreground(cYellow), - PlanPending: lipgloss.NewStyle().Foreground(cGrey), - - Bar: lipgloss.NewStyle().Foreground(cGrey), - BarKey: lipgloss.NewStyle().Foreground(cMagenta), - BarValue: lipgloss.NewStyle().Bold(true), - Usage: lipgloss.NewStyle().Foreground(cGrey), - - Overlay: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cBlue).Padding(0, 1), - OverlayTitle: lipgloss.NewStyle().Bold(true).Foreground(cBlue), - Selected: lipgloss.NewStyle().Foreground(cBlue).Bold(true), - Option: lipgloss.NewStyle(), - Input: lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(cGrey), - Border: lipgloss.NewStyle().Foreground(cGrey), +func light(theme, fgbg string) bool { + switch strings.ToLower(theme) { + case "light": + return true + case "dark": + return false + } + if i := strings.LastIndexByte(fgbg, ';'); i >= 0 { + bg := fgbg[i+1:] + return bg == "7" || bg == "15" + } + return false } diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go new file mode 100644 index 0000000..03f0b3c --- /dev/null +++ b/internal/tui/wizard.go @@ -0,0 +1,358 @@ +package tui + +import ( + "fmt" + "io" + "os" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Step places one question in a numbered flow (atlas init): the header +// shows where the user is, and the answered step collapses to one line. +type Step struct { + N, Total int + Title string +} + +// Choice is one option of a step: a label and a faint detail after it. +type Choice struct { + Label, Detail string +} + +// wizardWidth is the frame's width before the terminal reports its own. +const wizardWidth = 72 + +func frameWidth(term int) int { + if term <= 0 { + return wizardWidth + } + return max(min(term-2, 96), 40) +} + +// header is the breadcrumb over a rule, then "STEP 2 OF 4 ▮▮▮▮▯▯▯▯". +func (s Step) header(w int) string { + h := Header2("setup", strings.ToLower(s.Title)) + "\n" + Rule(w) + "\n" + if s.Total > 0 { + h += Upper(fmt.Sprintf("step %d of %d", s.N, s.Total)) + " " + Bar(s.N, s.Total, 4*s.Total) + "\n" + } + return h +} + +// footer is a rule over the key hints. +func footer(w int, keys ...string) string { + return Rule(w) + "\n" + Keys(keys...) + "\n" +} + +// summary is the tree line an answered step leaves behind; Ready closes +// the tree. +func (s Step) summary(answer string) string { + return TreeLine(false, s.Title, Default.Answer.Render(answer)) +} + +// cancelled is the line a step left with esc leaves behind. +func (s Step) cancelled() string { + return TreeLine(true, s.Title, Default.Error.Render("cancelled")) +} + +// StepDone prints the line of a step answered without a question (one +// organisation, a flag on the command line). +func StepDone(w io.Writer, s Step, answer string) { + fmt.Fprintln(w, s.summary(answer)) +} + +// Ready closes a flow's tree: a green hexagon and the message. +func Ready(w io.Writer, msg string, notes ...string) { + fmt.Fprintln(w, Default.Tree.Render("└")+" "+Default.Hex.Render("⬢")+" "+Default.Bold.Render(msg)) + for _, n := range notes { + fmt.Fprintln(w, " "+Default.Faint.Render(n)) + } +} + +// row renders one option as a tree line: ├ (└ for the last), the label, +// and its detail; the cursor's row is ▸ with the label on the selection +// background and the detail in green. +func row(cursor, last bool, mark, label, detail string) string { + if mark != "" { + mark += " " + } + if cursor { + if detail != "" { + detail = " " + Default.Answer.Render(detail) + } + return Default.Tree.Render("▸") + " " + mark + Default.Highlight.Render(" "+label+" ") + detail + "\n" + } + if detail != "" { + detail = " " + Default.Faint.Render(detail) + } + glyph := "├" + if last { + glyph = "└" + } + return Default.Tree.Render(glyph) + " " + mark + " " + Default.Option.Render(label) + " " + detail + "\n" +} + +func labelWidth(choices []Choice) int { + w := 0 + for _, c := range choices { + w = max(w, lipgloss.Width(c.Label)) + } + return w +} + +// ── Single choice ──────────────────────────────────────────────────────────── + +type stepSelect struct { + step Step + question string + choices []Choice + cursor int + chosen int + done bool + height int // the terminal's, once known; long lists scroll + width int +} + +// chrome is the lines a step draws around its list: breadcrumb, rule, +// step line, question, the "more" markers, the footer and the blank lines. +const chrome = 14 + +// window is the slice of rows [from, to) that fits with the cursor in it. +func window(n, cursor, height int) (from, to int) { + rows := n + if height > 0 { + rows = max(height-chrome, 4) + } + if n <= rows { + return 0, n + } + from = min(max(cursor-rows/2, 0), n-rows) + return from, from + rows +} + +func (m stepSelect) Init() tea.Cmd { return nil } + +func (m stepSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.height, m.width = ws.Height, ws.Width + return m, nil + } + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + switch key.String() { + case "up", "k", "shift+tab": + m.cursor = (m.cursor - 1 + len(m.choices)) % len(m.choices) + case "down", "j", "tab": + m.cursor = (m.cursor + 1) % len(m.choices) + case "home", "g": + m.cursor = 0 + case "end", "G": + m.cursor = len(m.choices) - 1 + case "enter", "space": + m.chosen, m.done = m.cursor, true + return m, tea.Quit + case "esc", "q", "ctrl+c": + m.done = true + return m, tea.Quit + default: + // A digit picks that option directly. + if r := key.String(); len(r) == 1 && r[0] >= '1' && r[0] <= '9' && int(r[0]-'1') < len(m.choices) { + m.cursor = int(r[0] - '1') + } + } + return m, nil +} + +func (m stepSelect) View() tea.View { + if m.done { + if m.chosen < 0 { + return tea.NewView(m.step.cancelled() + "\n") + } + return tea.NewView(m.step.summary(m.choices[m.chosen].Label) + "\n") + } + w := frameWidth(m.width) + var b strings.Builder + b.WriteString(m.step.header(w) + "\n") + b.WriteString(Default.Bold.Render(m.question) + "\n\n") + lw := labelWidth(m.choices) + from, to := window(len(m.choices), m.cursor, m.height) + if from > 0 { + b.WriteString(Default.Tree.Render("│") + " " + Default.Faint.Render(fmt.Sprintf("↑ %d more", from)) + "\n") + } + for i := from; i < to; i++ { + c := m.choices[i] + b.WriteString(row(i == m.cursor, i == len(m.choices)-1, "", fmt.Sprintf("%-*s", lw, c.Label), c.Detail)) + } + if to < len(m.choices) { + b.WriteString(Default.Tree.Render("│") + " " + Default.Faint.Render(fmt.Sprintf("↓ %d more", len(m.choices)-to)) + "\n") + } + b.WriteString("\n" + footer(w, "↑↓", "navigate", "ENTER", "select", "ESC", "cancel")) + return tea.NewView(b.String()) +} + +// StepSelect asks one question of a flow and returns the chosen index, or +// ErrCancelled. +func StepSelect(s Step, question string, choices []Choice, initial int) (int, error) { + return stepSelectWith(s, question, choices, initial, os.Stdin, os.Stdout) +} + +func stepSelectWith(s Step, question string, choices []Choice, initial int, in io.Reader, out io.Writer) (int, error) { + if len(choices) == 0 { + return -1, fmt.Errorf("%s: nothing to choose from", s.Title) + } + if initial < 0 || initial >= len(choices) { + initial = 0 + } + m := stepSelect{step: s, question: question, choices: choices, cursor: initial, chosen: -1} + final, err := tea.NewProgram(m, tea.WithInput(in), tea.WithOutput(out)).Run() + if err != nil { + return -1, err + } + if fm := final.(stepSelect); fm.chosen >= 0 { + return fm.chosen, nil + } + return -1, ErrCancelled +} + +// ── Several choices ────────────────────────────────────────────────────────── + +// stepMulti is a checklist whose first row selects or clears every option. +type stepMulti struct { + step Step + question string + allLabel string + choices []Choice + checked []bool + cursor int // 0 is the "all" row; i+1 is choices[i] + done bool + ok bool + width int +} + +func (m stepMulti) allChecked() bool { + for _, c := range m.checked { + if !c { + return false + } + } + return len(m.checked) > 0 +} + +func (m stepMulti) setAll(on bool) stepMulti { + for i := range m.checked { + m.checked[i] = on + } + return m +} + +func (m stepMulti) Init() tea.Cmd { return nil } + +func (m stepMulti) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + return m, nil + } + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + rows := len(m.choices) + 1 + switch key.String() { + case "up", "k", "shift+tab": + m.cursor = (m.cursor - 1 + rows) % rows + case "down", "j", "tab": + m.cursor = (m.cursor + 1) % rows + case "space", "x": + if m.cursor == 0 { + m = m.setAll(!m.allChecked()) + } else { + m.checked[m.cursor-1] = !m.checked[m.cursor-1] + } + case "a": + m = m.setAll(!m.allChecked()) + case "n": + m = m.setAll(false) + case "enter": + m.done, m.ok = true, true + return m, tea.Quit + case "esc", "q", "ctrl+c": + m.done = true + return m, tea.Quit + } + return m, nil +} + +func (m stepMulti) picked() []int { + var out []int + for i, c := range m.checked { + if c { + out = append(out, i) + } + } + return out +} + +// box is a checkbox: ☒ chosen, ☐ not, ▣ for the "all" row when some are. +func box(on bool) string { + if on { + return Default.Check.Render("☒") + } + return Default.Off.Render("☐") +} + +func (m stepMulti) View() tea.View { + if m.done { + if !m.ok { + return tea.NewView(m.step.cancelled() + "\n") + } + var names []string + for _, i := range m.picked() { + names = append(names, m.choices[i].Label) + } + answer := strings.Join(names, ", ") + if answer == "" { + answer = "none" + } + return tea.NewView(m.step.summary(answer) + "\n") + } + w := frameWidth(m.width) + var b strings.Builder + b.WriteString(m.step.header(w) + "\n") + b.WriteString(Default.Bold.Render(m.question) + "\n\n") + lw := max(labelWidth(m.choices), lipgloss.Width(m.allLabel)) + n := len(m.picked()) + all := box(m.allChecked()) + if n > 0 && !m.allChecked() { + all = Default.Accent.Render("▣") + } + b.WriteString(row(m.cursor == 0, false, all, fmt.Sprintf("%-*s", lw, m.allLabel), fmt.Sprintf("%d of %d selected", n, len(m.choices)))) + for i, c := range m.choices { + b.WriteString(row(m.cursor == i+1, i == len(m.choices)-1, box(m.checked[i]), fmt.Sprintf("%-*s", lw, c.Label), c.Detail)) + } + b.WriteString("\n" + footer(w, "↑↓", "navigate", "SPACE", "toggle", "A", "all", "N", "none", "ENTER", "confirm", "ESC", "cancel")) + return tea.NewView(b.String()) +} + +// StepMulti asks for any number of options, with a first row that selects +// them all; it returns the chosen indexes (possibly none), or ErrCancelled. +func StepMulti(s Step, question, allLabel string, choices []Choice, checked []bool) ([]int, error) { + return stepMultiWith(s, question, allLabel, choices, checked, os.Stdin, os.Stdout) +} + +func stepMultiWith(s Step, question, allLabel string, choices []Choice, checked []bool, in io.Reader, out io.Writer) ([]int, error) { + m := stepMulti{step: s, question: question, allLabel: allLabel, choices: choices, checked: make([]bool, len(choices))} + copy(m.checked, checked) + final, err := tea.NewProgram(m, tea.WithInput(in), tea.WithOutput(out)).Run() + if err != nil { + return nil, err + } + fm := final.(stepMulti) + if !fm.ok { + return nil, ErrCancelled + } + return fm.picked(), nil +} diff --git a/internal/tui/wizard_test.go b/internal/tui/wizard_test.go new file mode 100644 index 0000000..9f05d7f --- /dev/null +++ b/internal/tui/wizard_test.go @@ -0,0 +1,103 @@ +package tui + +import ( + "io" + "regexp" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`) + +func plain(v tea.View) string { return ansiRE.ReplaceAllString(v.Content, "") } + +func hit(m tea.Model, code rune, text string) tea.Model { + m, _ = m.Update(tea.KeyPressMsg{Code: code, Text: text}) + return m +} + +func TestStepSelect(t *testing.T) { + var m tea.Model = stepSelect{step: Step{N: 2, Total: 4, Title: "Project"}, question: "Which Project?", + choices: []Choice{{Label: "backend", Detail: "matches"}, {Label: "+ Create"}}, chosen: -1} + s := plain(m.View()) + for _, want := range []string{"Atlas setup / project", "STEP 2 OF 4", "▮▮▮▮▮▮▮▮▯▯▯▯▯▯▯▯", "Which Project?", "▸ backend", "matches", "[ENTER] select"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q:\n%s", want, s) + } + } + m = hit(m, tea.KeyDown, "") + m = hit(m, tea.KeyEnter, "") + if fm := m.(stepSelect); fm.chosen != 1 || !fm.done { + t.Fatalf("%+v", fm) + } + if s := plain(m.View()); !strings.Contains(s, "├ Project") || !strings.Contains(s, "+ Create") { + t.Fatalf("summary: %s", s) + } +} + +func TestStepMultiAllRow(t *testing.T) { + var m tea.Model = stepMulti{step: Step{N: 3, Total: 4, Title: "Agents"}, allLabel: "All agents", + choices: []Choice{{Label: "Claude Code"}, {Label: "Codex"}, {Label: "Cursor"}}, checked: []bool{true, false, false}} + if s := plain(m.View()); !strings.Contains(s, "1 of 3 selected") { + t.Fatal(s) + } + m = hit(m, tea.KeySpace, " ") // on the "all" row: select every agent + if got := m.(stepMulti).picked(); len(got) != 3 { + t.Fatalf("all: %v", got) + } + m = hit(m, 'a', "a") // a again clears + if got := m.(stepMulti).picked(); len(got) != 0 { + t.Fatalf("none: %v", got) + } + m = hit(m, tea.KeyDown, "") + m = hit(m, tea.KeyDown, "") + m = hit(m, tea.KeySpace, " ") // Codex + m = hit(m, tea.KeyEnter, "") + fm := m.(stepMulti) + if got := fm.picked(); !fm.ok || len(got) != 1 || got[0] != 1 { + t.Fatalf("%v %v", fm.ok, got) + } + if s := plain(m.View()); !strings.Contains(s, "├ Agents") || !strings.Contains(s, "Codex") { + t.Fatal(s) + } + m = hit(stepMulti{choices: []Choice{{Label: "x"}}, checked: []bool{true}}, tea.KeyEscape, "") + if m.(stepMulti).ok { + t.Fatal("esc must cancel") + } +} + +func TestStepSelectScrollsALongList(t *testing.T) { + choices := make([]Choice, 30) + for i := range choices { + choices[i] = Choice{Label: string(rune('a' + i%26))} + } + var m tea.Model = stepSelect{step: Step{Title: "Project"}, choices: choices, chosen: -1} + m, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + s := plain(m.View()) + if !strings.Contains(s, "↓ 24 more") || strings.Count(s, " more") != 1 { + t.Fatal(s) + } + for range 29 { + m = hit(m, tea.KeyDown, "") + } + if s := plain(m.View()); !strings.Contains(s, "↑ 24 more") || strings.Count(s, " more") != 1 { + t.Fatal(s) + } +} + +func TestIndentKeepsWrappedLinesOnTheStem(t *testing.T) { + var b strings.Builder + w := Indent(&b) + _, _ = io.WriteString(w, "short\n"+strings.Repeat("word ", 30)+"\n") + lines := strings.Split(strings.TrimRight(ansiRE.ReplaceAllString(b.String(), ""), "\n"), "\n") + if len(lines) < 3 { + t.Fatalf("%q", lines) + } + for _, l := range lines { + if !strings.HasPrefix(l, "│ ") || len([]rune(l)) > indentWidth+5 { + t.Fatalf("%q", l) + } + } +} diff --git a/internal/tui/work.go b/internal/tui/work.go new file mode 100644 index 0000000..a66ad83 --- /dev/null +++ b/internal/tui/work.go @@ -0,0 +1,182 @@ +package tui + +import ( + "errors" + "fmt" + "io" + "math" + "os" + "time" + + tea "charm.land/bubbletea/v2" +) + +// ShownError is an error a screen already showed the user; main does not +// print it again. +type ShownError struct{ Err error } + +func (e ShownError) Error() string { return e.Err.Error() } +func (e ShownError) Unwrap() error { return e.Err } + +// Shown reports whether err was already shown. +func Shown(err error) bool { + var s ShownError + return errors.As(err, &s) +} + +// StepFunc reports progress from inside a Work function: the line under the +// title changes to msg. +type StepFunc func(msg string) + +// FracFunc sets a Work bar to a known fraction, 0..1 (a download). +type FracFunc func(f float64) + +type ( + workTick struct{} + workFrac float64 + workStep string + workDone struct { + msg string + err error + } +) + +type workModel struct { + title string + step string + frame int + start time.Time + done bool + msg string + err error + output []string // finished steps, kept on screen + prog float64 // the bar, 0..1 + full bool // finished: the bar shows full a moment before the result + known bool // the bar follows reported fractions, not time +} + +type workShown struct{} + +// advance moves the bar: it eases toward 90% with time and jumps forward +// with each step, never back. +func (m *workModel) advance(step bool) { + if m.known { + return + } + el := time.Since(m.start).Seconds() + m.prog = max(m.prog, 0.9*(1-math.Exp(-el/2.5))) + if step { + m.prog += (0.95 - m.prog) * 0.3 + } +} + +func (m workModel) Init() tea.Cmd { return workTicker() } + +func workTicker() tea.Cmd { + return tea.Tick(110*time.Millisecond, func(time.Time) tea.Msg { return workTick{} }) +} + +func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case workTick: + if m.done || m.full { + return m, nil + } + m.frame++ + m.advance(false) + return m, workTicker() + case workFrac: + m.known = true + m.prog = max(m.prog, min(float64(msg), 1)) + case workStep: + if m.step != "" { + m.output = append(m.output, m.step) + } + m.step = string(msg) + m.advance(true) + case workDone: + m.msg, m.err = msg.msg, msg.err + if m.step != "" { + m.output = append(m.output, m.step) + m.step = "" + } + if msg.err != nil { + m.done = true + return m, tea.Quit + } + m.full, m.prog = true, 1 + return m, tea.Tick(220*time.Millisecond, func(time.Time) tea.Msg { return workShown{} }) + case workShown: + m.done = true + return m, tea.Quit + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + m.done, m.err = true, errors.New("interrupted") + return m, tea.Quit + } + } + return m, nil +} + +// barWidth is the progress bar's width on this terminal. +func barWidth() int { return max(min(Width()-4, 56), 16) } + +func (m workModel) View() tea.View { + var b []byte + if m.done { + for _, o := range m.output { + b = fmt.Appendf(b, "%s %s\n", Default.Tree.Render("├"), Default.Faint.Render(o)) + } + switch { + case m.err != nil: + b = fmt.Appendf(b, "%s %s %s\n", Default.Error.Render("✗"), Default.Bold.Render(m.title), Default.Error.Render(m.err.Error())) + case m.msg != "": + b = fmt.Appendf(b, "%s %s\n", Default.Success.Render("✓"), Default.Bold.Render(m.msg)) + } + return tea.NewView(string(b)) + } + el := time.Since(m.start).Round(100 * time.Millisecond) + b = fmt.Appendf(b, "%s %s\n", Default.Bold.Render(m.title), Default.Faint.Render(el.String())) + b = fmt.Appendf(b, "%s\n", Bar(int(m.prog*1000), 1000, barWidth())) + for _, o := range m.output { + b = fmt.Appendf(b, "%s %s\n", Default.Tree.Render("├"), Default.Faint.Render(o)) + } + if m.step != "" { + b = fmt.Appendf(b, "%s %s\n", Default.Tree.Render("└"), Default.Faint.Render(m.step)) + } + return tea.NewView(string(b)) +} + +// Work runs fn while a pulsing mark and the title show that something is +// happening; steps fn reports appear under it. On a terminal it ends with +// "✓ " or "✗ <error>" (the error comes back as a ShownError); +// elsewhere fn runs silently and msg is printed plain to w. +func Work(w io.Writer, title string, fn func(step StepFunc) (string, error)) error { + return WorkProgress(w, title, func(step StepFunc, _ FracFunc) (string, error) { return fn(step) }) +} + +// WorkProgress is Work for a job that knows how far it is: frac drives the +// bar (a download), steps still show under it. +func WorkProgress(w io.Writer, title string, fn func(step StepFunc, frac FracFunc) (string, error)) error { + if !Interactive() { + msg, err := fn(func(string) {}, func(float64) {}) + if err == nil && msg != "" { + fmt.Fprintln(w, msg) + } + return err + } + p := tea.NewProgram(workModel{title: title, start: time.Now()}, tea.WithOutput(os.Stdout), tea.WithInput(os.Stdin)) + go func() { + msg, err := fn(func(s string) { p.Send(workStep(s)) }, func(f float64) { p.Send(workFrac(f)) }) + p.Send(workDone{msg, err}) + }() + final, err := p.Run() + if err != nil { + return err + } + m := final.(workModel) + if m.err != nil { + return ShownError{m.err} + } + return nil +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..48cdf05 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,216 @@ +// Package update replaces the running atlas binary with a release from +// GitHub: the same archive and checksums.txt the installer (install.sh) +// downloads, verified the same way, swapped in with a rename. +package update + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// Repo is where releases are published. +const Repo = "tryatlas/atlas-cli" + +// maxArchive bounds a download. +const maxArchive = 128 << 20 + +// Source is where releases are read from; tests point it at a fake. +type Source struct { + API string // https://api.github.com/repos/<repo> + Base string // https://github.com/<repo>/releases/download + HTTP *http.Client +} + +// GitHub is the real source. ATLAS_UPDATE_BASE_URL replaces the download +// base, as ATLAS_BASE_URL does for the installer. +func GitHub() Source { + s := Source{API: "https://api.github.com/repos/" + Repo, Base: "https://github.com/" + Repo + "/releases/download", + HTTP: &http.Client{Timeout: 2 * time.Minute}} + if b := os.Getenv("ATLAS_UPDATE_BASE_URL"); b != "" { + s.Base = strings.TrimRight(b, "/") + } + return s +} + +func (s Source) get(ctx context.Context, url string, limit int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "atlas-cli-update") + resp, err := s.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", url, resp.Status) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("%s: larger than %d bytes", url, limit) + } + return b, nil +} + +// Latest is the tag of the latest published release, "v0.4.0". +func (s Source) Latest(ctx context.Context) (string, error) { + b, err := s.get(ctx, s.API+"/releases/latest", 1<<20) + if err != nil { + return "", err + } + var r struct { + Tag string `json:"tag_name"` + } + if err := json.Unmarshal(b, &r); err != nil || r.Tag == "" { + return "", errors.New("the latest release names no tag") + } + return r.Tag, nil +} + +// Archive is the release archive for this platform. +func Archive(tag, goos, goarch string) string { + return fmt.Sprintf("atlas_%s_%s_%s.tar.gz", strings.TrimPrefix(tag, "v"), goos, goarch) +} + +// Download fetches this platform's archive for tag, checks it against the +// release's checksums.txt, and returns the atlas binary inside it. +func (s Source) Download(ctx context.Context, tag string) ([]byte, error) { + name := Archive(tag, runtime.GOOS, runtime.GOARCH) + sums, err := s.get(ctx, s.Base+"/"+tag+"/checksums.txt", 1<<20) + if err != nil { + return nil, err + } + want := "" + sc := bufio.NewScanner(bytes.NewReader(sums)) + for sc.Scan() { + if f := strings.Fields(sc.Text()); len(f) == 2 && f[1] == name { + want = f[0] + } + } + if want == "" { + return nil, fmt.Errorf("%s is not in the release's checksums.txt (no build for %s/%s?)", name, runtime.GOOS, runtime.GOARCH) + } + arch, err := s.get(ctx, s.Base+"/"+tag+"/"+name, maxArchive) + if err != nil { + return nil, err + } + sum := sha256.Sum256(arch) + if hex.EncodeToString(sum[:]) != want { + return nil, fmt.Errorf("%s does not match its checksum; nothing was changed", name) + } + return binaryFrom(arch) +} + +// binaryFrom extracts the "atlas" file from a tar.gz. +func binaryFrom(archive []byte) ([]byte, error) { + zr, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, err + } + tr := tar.NewReader(zr) + for { + h, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil, errors.New("the archive holds no atlas binary") + } + if err != nil { + return nil, err + } + if h.Typeflag == tar.TypeReg && filepath.Base(h.Name) == "atlas" { + return io.ReadAll(io.LimitReader(tr, maxArchive)) + } + } +} + +// Replace swaps the binary at path for bin: written next to it, then +// renamed over it, so a running atlas keeps its open file and a failure +// leaves the old one in place. +func Replace(path string, bin []byte) error { + mode := os.FileMode(0o755) + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode().Perm() | 0o111 + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".atlas-update-*") + if err != nil { + return fmt.Errorf("cannot write next to %s: %w", path, err) + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(bin); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmp.Name(), mode); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +// Executable is the running binary's real path. +func Executable() (string, error) { + p, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(p) +} + +// Newer reports that release tag is newer than the running version. A +// development build ("dev", "0.4.0-dev") is older than its release. +func Newer(current, tag string) bool { + c, cpre := parse(current) + t, tpre := parse(tag) + if c == nil { + return true + } + if t == nil { + return false + } + for i := range 3 { + if t[i] != c[i] { + return t[i] > c[i] + } + } + return cpre && !tpre +} + +// parse reads "v1.2.3[-pre]"; nil when it is not a version. +func parse(v string) ([]int, bool) { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + core, pre, _ := strings.Cut(v, "-") + parts := strings.Split(core, ".") + if len(parts) != 3 { + return nil, false + } + out := make([]int, 3) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return nil, false + } + out[i] = n + } + return out, pre != "" +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..42837ea --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,101 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Fake builds a release server for tag holding bin; corrupt breaks the +// checksum. +func Fake(t *testing.T, tag string, bin []byte, corrupt bool) Source { + t.Helper() + var arch bytes.Buffer + zw := gzip.NewWriter(&arch) + tw := tar.NewWriter(zw) + for name, body := range map[string][]byte{"README.md": []byte("readme"), "atlas": bin} { + _ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}) + _, _ = tw.Write(body) + } + _ = tw.Close() + _ = zw.Close() + sum := sha256.Sum256(arch.Bytes()) + hexsum := hex.EncodeToString(sum[:]) + if corrupt { + hexsum = strings.Repeat("0", 64) + } + name := Archive(tag, runtime.GOOS, runtime.GOARCH) + mux := http.NewServeMux() + mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, `{"tag_name":%q}`, tag) + }) + mux.HandleFunc("/dl/"+tag+"/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "%s %s\n%s atlas_x_plan9_mips.tar.gz\n", hexsum, name, strings.Repeat("1", 64)) + }) + mux.HandleFunc("/dl/"+tag+"/"+name, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(arch.Bytes()) }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return Source{API: srv.URL + "/api", Base: srv.URL + "/dl", HTTP: srv.Client()} +} + +func TestDownloadVerifiesAndExtracts(t *testing.T) { + ctx := context.Background() + src := Fake(t, "v9.9.9", []byte("new binary"), false) + tag, err := src.Latest(ctx) + if err != nil || tag != "v9.9.9" { + t.Fatalf("%q %v", tag, err) + } + bin, err := src.Download(ctx, tag) + if err != nil || string(bin) != "new binary" { + t.Fatalf("%q %v", bin, err) + } + if _, err := Fake(t, "v9.9.9", []byte("x"), true).Download(ctx, "v9.9.9"); err == nil || !strings.Contains(err.Error(), "checksum") { + t.Fatalf("a bad checksum must refuse: %v", err) + } + if _, err := src.Download(ctx, "v1.0.0"); err == nil { + t.Fatal("an unknown tag must fail") + } +} + +func TestReplaceKeepsTheModeAndSwapsAtomically(t *testing.T) { + p := filepath.Join(t.TempDir(), "atlas") + _ = os.WriteFile(p, []byte("old"), 0o700) + if err := Replace(p, []byte("new")); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + fi, _ := os.Stat(p) + if string(b) != "new" || fi.Mode().Perm() != 0o711 { + t.Fatalf("%q %v", b, fi.Mode()) + } + left, _ := filepath.Glob(filepath.Join(filepath.Dir(p), ".atlas-update-*")) + if len(left) != 0 { + t.Fatalf("temp files left: %v", left) + } +} + +func TestNewer(t *testing.T) { + for _, c := range []struct { + cur, tag string + want bool + }{ + {"0.3.4", "v0.4.0", true}, {"0.4.0", "v0.4.0", false}, {"0.4.1", "v0.4.0", false}, + {"0.4.0-dev", "v0.4.0", true}, {"dev", "v0.1.0", true}, {"0.10.0", "v0.9.9", false}, + {"1.0.0", "v1.0.1", true}, {"0.4.0", "nonsense", false}, + } { + if got := Newer(c.cur, c.tag); got != c.want { + t.Errorf("Newer(%q, %q) = %v", c.cur, c.tag, got) + } + } +}