diff --git a/CLAUDE.md b/CLAUDE.md index 30981c4..8b0f470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,132 +4,116 @@ Guidance for Claude Code (claude.ai/code) when working in this repo. ## What this is -`openmelon` is a content-creation agent CLI. Three usage modes: +`openmelon` is a content-creation agent CLI — **pure TypeScript / Node**. It was +originally Go + a thin npm wrapper; the engine, runtime, and CLI were migrated to +TypeScript and the entire Go tree was deleted. There is no Go left — do not add any. -1. **Interactive TUI** — `openmelon` (no args, in a project) drops into a bubbletea REPL with slash commands, palette, model + skill pickers, bash approval modal, session resume. -2. **Headless prompt** — `openmelon -p ""`. Inside an OpenMelon project, runs the same tool-driven runtime as the TUI without the TUI. Outside a project, falls back to the legacy one-shot skillplus → LLM JSON → optional image/artifact path. Used by integrations and scripts. -3. **Public Go surface** — `pkg/openmelon` currently exposes version metadata; `pkg/contracts` holds public contract types for embedding/integration surfaces. +The whole product lives in **`tui/`** and ships as the npm package `@e8s/openmelon`. -> Repo: https://github.com/eight-acres-lab/openmelon -> Module: `github.com/eight-acres-lab/openmelon` +Three usage modes: -## Layout +1. **Interactive TUI** — `openmelon` (no args, inside a project) opens an Ink/React + REPL with slash commands, a model/skill picker, a bash approval modal, and session + resume. +2. **Headless one-shot** — `openmelon -p ""`. Same native runtime, no TUI; + streams progress, records the run to a session dir. +3. **Management CLI** — `openmelon `. + +> Repo: https://github.com/eight-acres-lab/openmelon · package: `@e8s/openmelon` + +## Layout (everything is under `tui/`) ``` -cmd/openmelon/ - main.go subcommand dispatch + legacy flag-based one-shot - cmd_init.go openmelon init - cmd_project.go project list|use|show|set-key|unset-key|keys - cmd_registry.go character / reference / material add|list|show|rm - cmd_search.go openmelon search - cmd_repl.go runRepl: builds runtime + dispatches TUI vs bufio - cmd_resume.go openmelon resume [] - cmd_setup.go openmelon setup (re-run auth wizard) - agent_runtime.go headless `-p` path inside a project - publish.go --publish vbox helper - -internal/ - userconfig/ ~/.openmelon/{config,credentials,projects}.json - + IsTrusted / ResolveAPIKey (project → global → env) - projectx/ /.openmelon/project.json + Settings + .gitignore - registry/ characters / references / materials on-disk store - (.search files, source-of-truth for description + tags) - search/ tag + grep, no vectors - llm/ pluggable Client (Complete/Stream) + ToolCaller (Chat) - + StreamingToolCaller (StreamChat) + Usage tracking - imagegen/ pluggable Generator with ReferenceImages support - + retry on transient TLS / 5xx + DisableKeepAlives - tools/ tool registry + builtin tools (list_characters, - get_character, search, compile_skill, generate_image, - save_artifact, bash, finish, ...) - + bash judge LLM + per-session allowlist - runtime/ tool-using agent loop driven by llm.ToolCaller - + Tracer interface + History support for multi-turn - session/ per-run messages.jsonl + meta.json + Recent / LoadHistory - onboard/ first-run wizards (trust → auth → project init) as - ONE alt-screen bubbletea program with state machine - + providers.go (public Provider / Preset for /model) - tui/ bubbletea TUI: model.go (state machine), tui.go (entry), - tracer.go (runtime → tea.Msg bridge), style.go, keys.go, - messages.go (per-event tea.Msg types) - repl/ bufio fallback REPL for non-tty contexts (CI, pipes) - skillplus/ subprocess wrapper to the `skillplus` CLI + ListSkills - agent/ legacy 0.2 one-shot agent (used outside a project) - artifacts/ legacy artifact write helper - provenance/ legacy provenance JSONL helper - project/ legacy 0.1 project.json loader - workflow/ legacy 0.1 declarative workflow runner - generation/ legacy 0.1 generation providers (shell + LLM-backed adapter) - version/ - -pkg/ - contracts/ public Go types - openmelon/ public version metadata (Version constant lives here) - -npm/ @e8s/openmelon Node distribution (downloads the binary) -examples/ - food-exploration/ legacy 0.1 declarative example - integrations/ Skill files for Claude Code, Cursor -assets/ logo + provenance -docs/ design notes, testing recipe -scripts/release.sh tag + build × 4 platforms + GH release + npm publish +tui/ + bin/openmelon.js launcher: runs dist/cli.js (built) or src/cli.ts via tsx (dev) + src/ + cli.ts subcommand dispatch (init/setup/resume/-p/help + management) + main.tsx Ink entry (runTui) + App.tsx the TUI: state machine, slash commands, overlays, approval modal + commands/ management subcommands: init, setup, project, registry, + search, session, space (+ common helpers) + components/ Ink components: Header, Transcript, PromptInput, StatusLine, + WorkingLine, SelectorPanel, SetupPanel, SlashPalette + core/ project (workdir .openmelon/project.json), config + (~/.openmelon/{config,credentials,projects}.json), session, + registry (characters/references/materials), space (creative + continuity), approvals (bash gate), providers, skillplus, fs + runtime/ THE NATIVE TS RUNTIME (no subprocess, no Go): + index.ts createRuntimeClient → always the native client + nativeClient.ts drives the agent loop; lazy session, resume, clearHistory + nativeConfig.ts loadNativeRuntimeBootstrap + buildSystemPrompt + nativeTools.ts the tools: registry reads, generate_image, continuity, + compile_skill, save_artifact, bash (gated), web_search/fetch + openaiCompat.ts OpenAI/OpenRouter chat+tools (streaming) + image generation + (honors reference images); Anthropic-style handled here too + webTools.ts web_search + web_fetch (DuckDuckGo, domain allow/block) + sessionStore.ts per-run messages.jsonl + meta.json + protocol.ts RuntimeClient / RuntimeEvent shared types + state/ TUI reducer + inputEditor (line editing) + types + terminal/ anchoredStdout, cursorAnchor, markdown (marked), wrap, clipboard + onboarding/ first-run wizard (trust → auth → project init) + tsconfig.json typecheck (NodeNext, strict) + tsconfig.build.json emit to dist/ ``` ## Commands ```bash -go build -ldflags "-X github.com/eight-acres-lab/openmelon/internal/version.Version=$(git describe --tags --always)" -o ./openmelon ./cmd/openmelon -go test ./... - -# Local install for testing: -go install -ldflags "-X github.com/eight-acres-lab/openmelon/internal/version.Version=v0.x-dev" ./cmd/openmelon +make build # cd tui && npm install --ignore-scripts && npm run build → tui/dist +make check # typecheck (tsc --noEmit) +make dev # run the TUI from source (tsx) +make install # build then npm link the openmelon bin +cd tui && npm test # build + node --test on dist/**/*.test.js ``` -## Architecture conventions - -- **Module path**: `github.com/eight-acres-lab/openmelon`. - -- **Dep policy** (per package): - - `internal/llm`, `internal/imagegen`, `internal/registry`, `internal/projectx`, `internal/userconfig`, `internal/runtime`, `internal/tools`, `internal/session`, `internal/search`: pure stdlib + net/http + encoding/json. No vendor SDKs. No YAML / CLI-parser deps. - - `internal/tui` and `internal/onboard`: Charm stack allowed (bubbletea, lipgloss, bubbles, textinput, textarea, viewport, spinner, key). These are the canonical Go TUI framework and impossible to replicate in stdlib. Confine to these two packages so the runtime stays light. - - `cmd/openmelon`: imports anything; orchestrates. - -- **Slug rules are uniform.** `projectx.ValidateID` and `registry.ValidateSlug` both require kebab-case `[a-z][a-z0-9-]*`, len 2–64. Material slugs are `m-` so the hash satisfies the rule. - -- **No vendor model defaults baked into source.** Code returns `ErrModelRequired` when no model id is passed. Users get curated preset lists via the auth wizard / `/model` selector (see `internal/onboard/auth.go:providerOptions`); choices are persisted to `project.json:defaults` and `~/.openmelon/config.json:defaults`. - -- **Subprocess to skillplus.** Don't reimplement skill compilation in Go. Contract is JSON-in / JSON-out via `internal/skillplus`. `ListSkills` shells `skillplus list --json`. - -- **Tool dispatch is synchronous from a worker goroutine.** The bash tool's approval flow uses a reply channel + tea.Msg to bridge into the bubbletea event loop. See `tools/bash.go` + `tui/messages.go:approvalRequestMsg`. +Release: `./scripts/release.sh vX.Y.Z [--dry-run]` (bumps tui/package.json, builds, +`npm publish`es @e8s/openmelon — no native binaries). -- **The bash tool is gated by 4 tiers**: trusted-mode bypass → per-session allowlist → judge LLM (AUTO/ASK/BLOCK) → user modal. Mode is `project.json:settings.bash_permission_mode` (strict/auto/trusted). Headless `-p` wires the judge but no user approval modal: `auto` can run judge-AUTO commands, judge-ASK commands fail without an approval gate, `strict` requires approval for non-blocked commands and therefore fails headless, and `trusted` bypasses checks. +## Conventions -- **API key resolution order**: project credentials.json → global credentials.json → env var (e.g. `OPENROUTER_API_KEY`). Both TUI and headless `-p` go through `userconfig.ResolveAPIKey(workdir, provider)`. +- **Config is GLOBAL only.** Model, provider, API key, base_url, and the image + model/provider live in `~/.openmelon/{config.json,credentials.json}` — never in the + project. The project file carries identity, persona, constraints, continuity, and the + one per-project behaviour knob `reasoning_effort`. All resolution goes through + `core/config.ts` helpers: `resolveProvider` / `resolveApiKey` (global → env, no + workdir lookup) and `setGlobalDefaults` / `setGlobalBaseUrl` / `setGlobalApiKey` / + `unsetGlobalApiKey`. Do not reintroduce project-level model/key overrides. -- **Sessions are append-only.** A new session dir is created per `openmelon` launch (or per `openmelon resume`); the prior dir is never modified. `meta.json` records `resumed_from` for traceability. +- **Native runtime only.** `runtime/index.ts` always returns the native TS client; + `OPENMELON_RUNTIME=process|go` is accepted but ignored (warns). No subprocess engine. -- **Streaming**: `llm.StreamingToolCaller.StreamChat` parses SSE, fires `OnText` for each text delta, accumulates tool-call deltas (vendors split function.arguments across many chunks) into a single ToolCall list at the end. `stream_options.include_usage=true` makes the final chunk carry the Usage block. +- **The bash tool is gated** (strict / auto / trusted via `project.json:settings. + bash_permission_mode`): trusted bypass → per-session allowlist → judge LLM → user + approval modal. Headless `-p` wires the judge but has no modal. -- **TUI rendering**: viewport content is bottom-anchored when transcript is shorter than the viewport (pad with leading newlines). The textarea auto-grows from 1 line up to 10 as the user types newlines. Active state replaces the input area entirely (running spinner, /settings, /model, /skill, approval modal). +- **Publishing to V-Box is a bundled capability, not a command.** `@e8s/vbox-cli` is a + dependency of `tui/`; `runtime/nativeTools.ts:bashEnv()` prepends `node_modules/.bin` + to the bash PATH so the agent can run `vbox-cli upload` / `vbox-cli post` itself. The + system prompt (`buildSystemPrompt`) tells the model how. There is no `/publish`. -## Adding an LLM provider +- **Slug rules** are uniform kebab-case `[a-z][a-z0-9-]*`, len 2–64. Material slugs are + `m-`. -1. Implement `llm.Client` (Complete + Stream + Provider + Model). For tool-use support, also implement `ToolCaller.Chat` and ideally `StreamingToolCaller.StreamChat`. Reuse `internal/llm/sse.go` for SSE parsing. -2. Register the constructor in `llm.New` (factory.go). -3. Add a row to `internal/onboard/auth.go:providerOptions` so the auth wizard / `/model` selector know about it. +- **No vendor model defaults baked into source.** Users pick from curated presets in the + auth wizard / `/model` selector (`core/providers.ts`); the choice persists to global + `config.json:defaults`. -## Adding an image provider +- **Sessions are append-only.** A new session dir per launch (or per `resume`); the + prior dir is never modified. -1. Implement `imagegen.Generator` (Generate + Provider + Model). Honor `GenerateOptions.ReferenceImages`. Use `freshTransport()` and `transientHTTPDo` for the HTTP client (see `internal/imagegen/retry.go`). -2. Register in `imagegen.New` (factory.go). -3. Add to the relevant providerOptions row's `imagePresets`. +- **Tests** are colocated `*.test.ts`, run via `node --test` on the built `dist/`. Tests + that exercise the runtime must isolate `OPENMELON_HOME` to a temp dir and point the + global default at a dead local port so they never touch the developer's real config or + API key (see `runtime/nativeClient.test.ts:useTempGlobalConfig`). -## Adding a slash command +## Adding a tool -1. Append to `slashCommands` in `internal/tui/model.go`. -2. Add a `case "/"` branch in `handleSlash`. -3. If it needs its own state, add a `state*` constant + `update*` + `render*` + an `overlayRows` entry in `recomputeLayout`. +Add it in `runtime/nativeTools.ts` (it returns the full `NativeTool[]`), give it a JSON +schema + handler, and it is automatically advertised in `buildSystemPrompt`'s tool list. -## Versioning +## Adding an LLM / image provider -`internal/version/version.go` defaults to `"dev"`. `pkg/openmelon/openmelon.go` exposes the `Version` constant for embedded use. Release builds override via `-ldflags` (see `scripts/release.sh`). +Wire it in `runtime/openaiCompat.ts` (chat + image are OpenAI-compatible) and add a row +to `core/providers.ts` so the auth wizard / `/model` selector know about it. Honor +reference images on the image path. diff --git a/Makefile b/Makefile index 419ab9c..6294e00 100644 --- a/Makefile +++ b/Makefile @@ -1,51 +1,32 @@ -CARGO ?= cargo NPM ?= npm -PREFIX ?= $(HOME)/.local -BINDIR ?= $(PREFIX)/bin +TUI ?= tui -.PHONY: build test rust-build rust-test rust-install tui-install tui-dev tui-check clean help +.PHONY: build check dev start install clean help -build: - go build -o openmelon ./cmd/openmelon/ - -test: - go test ./... +# openmelon is a pure-TypeScript project; everything lives in tui/. -rust-build: - $(CARGO) build --manifest-path rust/Cargo.toml - -rust-test: - $(CARGO) test --manifest-path rust/Cargo.toml +build: + cd $(TUI) && $(NPM) install --ignore-scripts && $(NPM) run build -rust-install: rust-build - mkdir -p "$(BINDIR)" - cp rust/target/debug/openmelon-tui "$(BINDIR)/openmelon-rust" - @echo "installed $(BINDIR)/openmelon-rust" +check: + cd $(TUI) && $(NPM) install --ignore-scripts && $(NPM) run check -tui-check: - cd tui && $(NPM) install && $(NPM) run check && $(NPM) run build +dev: + cd $(TUI) && $(NPM) install --ignore-scripts && $(NPM) run dev -tui-dev: - cd tui && $(NPM) install && $(NPM) run dev +start: build + cd $(TUI) && $(NPM) start -tui-install: - cd tui && $(NPM) install && $(NPM) run build - mkdir -p "$(BINDIR)" - printf '#!/usr/bin/env sh\nexec node "%s/tui/bin/openmelon.js" "$$@"\n' "$(CURDIR)" > "$(BINDIR)/openmelon" - chmod +x "$(BINDIR)/openmelon" - @echo "installed TS TUI $(BINDIR)/openmelon" +install: build + cd $(TUI) && $(NPM) link clean: - rm -f openmelon + rm -rf $(TUI)/dist help: - @echo "Available targets:" - @echo " build - Build the OpenMelon CLI" - @echo " test - Run all tests" - @echo " rust-build - Build the Rust TUI prototype" - @echo " rust-test - Test the Rust TUI prototype" - @echo " rust-install - Install Rust TUI as $(BINDIR)/openmelon-rust" - @echo " tui-check - Install/check/build the TS-first TUI" - @echo " tui-dev - Run the TS-first TUI in development mode" - @echo " tui-install - Install the TS-first TUI as $(BINDIR)/openmelon" - @echo " clean - Remove build artifacts" + @echo "build - install deps + compile TS to tui/dist" + @echo "check - typecheck (tsc --noEmit)" + @echo "dev - run the TUI from source (tsx)" + @echo "start - build then run the compiled CLI" + @echo "install - build then npm link the openmelon bin" + @echo "clean - remove tui/dist" diff --git a/README.md b/README.md index a61cbe7..ed0b3f7 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,11 @@ Same image model (`google/gemini-2.5-flash-image`), one shot each. The differenc npm install -g @e8s/openmelon @e8s/skillplus ``` -The npm package downloads the matching Go binary from GitHub Releases and verifies it against `SHASUMS256.txt`. To build from source: +`openmelon` is a pure-TypeScript Node CLI (no native binary). To run from source: ```bash -go install github.com/eight-acres-lab/openmelon/cmd/openmelon@latest +git clone https://github.com/eight-acres-lab/openmelon && cd openmelon +make install # build tui/ and npm link the `openmelon` bin ``` ## Configuration diff --git a/cmd/openmelon/agent_runtime.go b/cmd/openmelon/agent_runtime.go deleted file mode 100644 index d19a459..0000000 --- a/cmd/openmelon/agent_runtime.go +++ /dev/null @@ -1,215 +0,0 @@ -package main - -// agent_runtime.go — wires the new tool-driven runtime to the `-p` CLI -// flag when invoked inside an openmelon project. -// -// Outside a project, cmd/openmelon/main.go falls back to the legacy -// one-shot agent that compiles a single skillplus package and produces -// a single image. Inside a project, this path takes over: it gives the -// model a full tool box (list_characters, get_character, search, -// generate_image with reference images, save_artifact, finish) and -// records every turn into a session directory under -// .openmelon/sessions//. - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/imagegen" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/session" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/tools" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -func runAgentInProject(ctx context.Context, opts agentOpts, workdir string) error { - proj, err := projectx.Load(workdir) - if err != nil { - return fmt.Errorf("project: %w", err) - } - - // Per-project defaults override per-CLI defaults override env. - llmProvider := firstNonEmpty(opts.llmProvider, proj.Defaults.LLMProvider) - llmModel := firstNonEmpty(opts.llmModel, proj.Defaults.LLMModel) - imageProvider := firstNonEmpty(opts.imageProvider, proj.Defaults.ImageProvider) - imageModel := firstNonEmpty(opts.imageModel, proj.Defaults.ImageModel) - - // Then user defaults if both per-project and per-CLI are empty. - cfg, _ := userconfig.LoadConfig() - if cfg != nil { - llmProvider = firstNonEmpty(llmProvider, cfg.Defaults.LLMProvider) - llmModel = firstNonEmpty(llmModel, cfg.Defaults.LLMModel) - imageProvider = firstNonEmpty(imageProvider, cfg.Defaults.ImageProvider) - imageModel = firstNonEmpty(imageModel, cfg.Defaults.ImageModel) - } - if llmProvider == "" { - llmProvider = "auto" - } - if imageProvider == "" { - imageProvider = "openrouter" - } - - // Pull provider config from project.json → global config → - // credentials.json → env. Keeps `-p` and the interactive REPL using - // the same key/base-url source. - apiKey := "" - llmBaseURL := opts.llmBaseURL - if llmProvider != "auto" { - resolved := userconfig.ResolveProvider(workdir, llmProvider) - apiKey = resolved.APIKey - if llmBaseURL == "" { - llmBaseURL = resolved.BaseURL - } - } - llmClient, err := llm.New(llmProvider, apiKey, llmBaseURL, llmModel) - if err != nil { - switch { - case errors.Is(err, llm.ErrNoAPIKey): - return fmt.Errorf("no API key for %s — run `openmelon setup` to configure", - llmProvider) - case errors.Is(err, llm.ErrModelRequired): - return fmt.Errorf("--llm-model is required (or set defaults.llm_model in project.json)") - } - return fmt.Errorf("init LLM: %w", err) - } - tc, ok := llmClient.(llm.ToolCaller) - if !ok { - return fmt.Errorf("provider %q does not support tool calls — use --llm openai or --llm openrouter", llmClient.Provider()) - } - - var imgGen imagegen.Generator - if opts.imageEnabled { - imgResolved := userconfig.ResolveProvider(workdir, imageProvider) - imgBaseURL := opts.imageBaseURL - if imgBaseURL == "" { - imgBaseURL = imgResolved.BaseURL - } - imgGen, err = imagegen.New(imageProvider, imgResolved.APIKey, imgBaseURL, imageModel) - if err != nil { - switch { - case errors.Is(err, imagegen.ErrNoAPIKey): - envHint := "OPENAI_API_KEY" - if imageProvider == "openrouter" { - envHint = "OPENROUTER_API_KEY" - } - return fmt.Errorf("image generation requires %s (or pass --image=false)", envHint) - case errors.Is(err, imagegen.ErrModelRequired): - return fmt.Errorf("--image-model is required (or set defaults.image_model in project.json)") - } - return fmt.Errorf("init image generator: %w", err) - } - } - - // Open a session. - sess, err := session.New(workdir, proj.ID, opts.intent) - if err != nil { - return fmt.Errorf("session: %w", err) - } - defer sess.Close() - _ = sess.SetRuntimeInfo(llmClient.Provider(), llmClient.Model()) - _ = sess.AppendPrompt("user", opts.intent) - sessionHooks := sess.HookRecorder() - - // Build the tool registry around the project + session. The - // headless `-p` path runs the same tool stack as the TUI: judge - // LLM is wired so /settings:trusted/auto modes auto-run safe - // commands without prompting (no UI here to prompt). Bash in - // strict mode without an Approve func will error per-call — - // switch to /settings → trusted (or auto) for headless bash use. - reg := tools.NewRegistry() - tools.RegisterAll(reg, &tools.Env{ - Workdir: workdir, - Project: proj, - SessionDir: sess.Dir, - OutputDir: projectx.SessionOutputDir(workdir, sess.ID), - Compiler: &skillplus.Compiler{CompilerPath: opts.compilerPath}, - ImageGen: imgGen, - JudgeBash: tools.JudgeBashWithLLM(tc), - BashMode: string(proj.Settings.EffectiveBashMode()), - Hooks: sessionHooks, - }) - - rt := &runtime.Runtime{ - LLM: tc, - Registry: reg, - Trace: os.Stderr, - Hooks: hooks.ChainManagers(sessionHooks), - MaxSteps: 24, - ReasoningEffort: resolveReasoningEffort(proj, llmClient.Provider(), llmClient.Model()), - } - - systemPrompt := buildProjectSystemPrompt(proj, reg.Names()) - - fmt.Fprintf(os.Stderr, "[openmelon] project=%s session=%s llm=%s/%s", - proj.ID, sess.ID, llmClient.Provider(), llmClient.Model()) - if imgGen != nil { - fmt.Fprintf(os.Stderr, " image=%s/%s", imgGen.Provider(), imgGen.Model()) - } - fmt.Fprintln(os.Stderr) - fmt.Fprintf(os.Stderr, "[openmelon] intent: %s\n", opts.intent) - - res, err := rt.Run(ctx, runtime.RunInput{ - SystemPrompt: systemPrompt, - UserInput: opts.intent, - }) - if res != nil { - _ = sess.AppendMessages(res.Messages) - _ = sess.WriteSummary(res.FinishSummary, res.FinishArtifacts, res.Finished) - } - if err != nil { - return err - } - if res.FinishSummary != "" { - fmt.Fprintf(os.Stderr, "\n[openmelon] %s\n", res.FinishSummary) - } - for _, p := range res.FinishArtifacts { - fmt.Fprintf(os.Stderr, "[openmelon] artifact: %s\n", p) - } - fmt.Fprintf(os.Stderr, "[openmelon] session: %s\n", sess.Dir) - return nil -} - -// buildProjectSystemPrompt assembles the project-context system prompt. -// -// Sent as the first system message of every run inside a project. Lists -// the available tools so the model knows what it can call without -// re-reading them from the wire schema (which it sees too — but humans -// debugging this benefit from a plain-language list as well). -func buildProjectSystemPrompt(p *projectx.Project, toolNames []string) string { - var b strings.Builder - b.WriteString("You are openmelon, a content-creation agent operating inside a creator's project.\n\n") - fmt.Fprintf(&b, "Project: %s (%s)\n", p.Name, p.ID) - if p.Description != "" { - fmt.Fprintf(&b, "Description: %s\n", p.Description) - } - if p.Persona != "" { - fmt.Fprintf(&b, "Voice / persona: %s\n", p.Persona) - } - if len(p.Constraints) > 0 { - b.WriteString("House rules (must respect):\n") - for _, c := range p.Constraints { - fmt.Fprintf(&b, " - %s\n", c) - } - } - b.WriteString("\nWork like a senior creator operating a durable creative workspace. Before producing, decide whether the request starts a new creative space, continues an existing space, modifies canon, records feedback, plans future content, compacts long context, or produces an episode. Use plan_creator_workflow when the workflow is ambiguous. Use list_spaces and get_context_packet to load continuity context before continuing a series; pass the current creative intent as query and use max_* limits when context may be large. For a new durable space, create only a draft space with provisional assumptions, then ask concise clarification questions for high-impact choices before recording decisions, creating episodes, or treating anything as long-term canon. Assumptions and record_memory_item entries are provisional/low-authority; canon, activate_space, promote_memory_item, and record_decision entries require explicit user confirmation. After the user confirms the core direction, call activate_space with the confirmed decision before creating durable episodes. Record weak observations with record_memory_item, promote them only after confirmation, and use update_asset_weight to promote/demote reusable assets after feedback. Use record_compaction after enough history accumulates or when a selected context should become a reusable summary. For visual work, load known characters, scenes, typography, layout rules, and style references from continuity context and reusable assets before producing. Treat typography the same way as background or character continuity: a descriptive project-level rule or reusable reference asset that is included in image prompts, not a local font lookup. Generate visual outputs through `generate_image` with relevant reference_images and explicit prompt constraints. User-facing deliverables must be saved in visible project output directories such as `outputs/`; `.openmelon` is reserved for internal state, sessions, config, and continuity data. Do not use bash to discover local fonts, render SVG/HTML, compose images, or otherwise replace the image model's visual generation unless the user explicitly asks for local file processing. When done, call `finish` with a short summary and final artifact paths or updated continuity state.\n") - b.WriteString("\nAvailable tools: ") - b.WriteString(strings.Join(toolNames, ", ")) - b.WriteString("\n") - return b.String() -} - -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if v != "" { - return v - } - } - return "" -} diff --git a/cmd/openmelon/agent_runtime_test.go b/cmd/openmelon/agent_runtime_test.go deleted file mode 100644 index 491e67f..0000000 --- a/cmd/openmelon/agent_runtime_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -func TestProjectSystemPromptTreatsTypographyAsContinuityContext(t *testing.T) { - prompt := buildProjectSystemPrompt(&projectx.Project{ID: "p", Name: "Project"}, []string{"generate_image", "bash"}) - for _, want := range []string{ - "Treat typography the same way as background or character continuity", - "not a local font lookup", - "Do not use bash to discover local fonts", - "Generate visual outputs through `generate_image`", - } { - if !strings.Contains(prompt, want) { - t.Fatalf("system prompt missing %q:\n%s", want, prompt) - } - } -} diff --git a/cmd/openmelon/cmd_init.go b/cmd/openmelon/cmd_init.go deleted file mode 100644 index a9a43ca..0000000 --- a/cmd/openmelon/cmd_init.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "errors" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -// runInit is `openmelon init [id]`. -// -// Initializes the cwd as an openmelon project: creates .openmelon/, writes -// project.json, and registers the project in ~/.openmelon/projects.json. -// -// id defaults to the basename of the cwd, slugified. --name defaults to -// the same. --workdir overrides cwd (but keep id explicit when you do). -func runInit(args []string) error { - fs := flag.NewFlagSet("init", flag.ContinueOnError) - name := fs.String("name", "", "Human-readable project name (default: id)") - description := fs.String("description", "", "One-line summary of what this project is") - workdir := fs.String("workdir", "", "Project root (default: cwd)") - setCurrent := fs.Bool("set-current", true, "Make this the current project after init") - if err := parseInterspersed(fs, args); err != nil { - return err - } - - wd := *workdir - if wd == "" { - var err error - wd, err = os.Getwd() - if err != nil { - return fmt.Errorf("init: cwd: %w", err) - } - } - wd, err := filepath.Abs(wd) - if err != nil { - return err - } - - id := "" - if fs.NArg() > 0 { - id = fs.Arg(0) - } - if id == "" { - id = slugFromBase(filepath.Base(wd)) - } - if *name == "" { - *name = id - } - - p, err := projectx.Init(wd, id, *name) - initialized := true - if err != nil { - if errors.Is(err, projectx.ErrAlreadyInitialized) { - initialized = false - p, err = projectx.Load(wd) - if err != nil { - return err - } - id = p.ID - if *name == "" || *name == fs.Arg(0) { - *name = p.Name - } - } else { - return err - } - } - if *description != "" { - p.Description = *description - if err := projectx.Save(wd, p); err != nil { - return err - } - } - if err := userconfig.Register(p.ID, p.Name, wd); err != nil { - return fmt.Errorf("init: register: %w", err) - } - if *setCurrent { - if err := userconfig.SetCurrent(p.ID); err != nil { - return fmt.Errorf("init: set current: %w", err) - } - } - if initialized { - fmt.Printf("Initialized project %q at %s\n", p.ID, wd) - } else { - fmt.Printf("Registered existing project %q at %s\n", p.ID, wd) - } - if *setCurrent { - fmt.Println("Set as current project.") - } - return nil -} - -// slugFromBase converts a directory basename into a kebab-case slug. -// Falls back to "project" if the result would be empty after cleanup. -func slugFromBase(base string) string { - base = strings.ToLower(base) - var b strings.Builder - prevHy := false - for _, r := range base { - switch { - case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': - b.WriteRune(r) - prevHy = false - case r == ' ' || r == '_' || r == '-' || r == '.': - if !prevHy && b.Len() > 0 { - b.WriteByte('-') - prevHy = true - } - } - } - out := strings.Trim(b.String(), "-") - // projectx.ValidateID requires a leading letter. - if out == "" || (out[0] < 'a' || out[0] > 'z') { - out = "project-" + out - out = strings.TrimRight(out, "-") - } - if len(out) < 2 { - out = "project" - } - return out -} diff --git a/cmd/openmelon/cmd_project.go b/cmd/openmelon/cmd_project.go deleted file mode 100644 index 7a912e0..0000000 --- a/cmd/openmelon/cmd_project.go +++ /dev/null @@ -1,292 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - "text/tabwriter" - - "github.com/eight-acres-lab/openmelon/internal/onboard" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -// runProject dispatches `openmelon project `. -func runProject(args []string) error { - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "usage: openmelon project ...") - os.Exit(2) - } - switch args[0] { - case "list": - return runProjectList(args[1:]) - case "use": - return runProjectUse(args[1:]) - case "show": - return runProjectShow(args[1:]) - case "set-key": - return runProjectSetKey(args[1:]) - case "unset-key": - return runProjectUnsetKey(args[1:]) - case "keys": - return runProjectKeys(args[1:]) - default: - return fmt.Errorf("unknown project subcommand: %q", args[0]) - } -} - -func runProjectList(args []string) error { - fs := flag.NewFlagSet("project list", flag.ContinueOnError) - if err := parseInterspersed(fs, args); err != nil { - return err - } - projects, err := userconfig.LoadProjects() - if err != nil { - return err - } - cfg, err := userconfig.LoadConfig() - if err != nil { - return err - } - if len(projects.Entries) == 0 { - fmt.Println("No projects registered. Run `openmelon init` in a project dir.") - return nil - } - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tNAME\tWORKDIR\tCURRENT") - for _, e := range projects.Entries { - mark := "" - if e.ID == cfg.CurrentProject { - mark = "*" - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.ID, e.Name, e.Workdir, mark) - } - return tw.Flush() -} - -func runProjectUse(args []string) error { - if len(args) != 1 { - return fmt.Errorf("usage: openmelon project use ") - } - id := args[0] - if err := userconfig.SetCurrent(id); err != nil { - return err - } - if err := userconfig.MarkUsed(id); err != nil { - return err - } - fmt.Printf("Current project: %s\n", id) - return nil -} - -func runProjectShow(args []string) error { - wd, _, err := resolveProjectWorkdir(args) - if err != nil { - return err - } - p, err := projectx.Load(wd) - if err != nil { - return err - } - fmt.Printf("ID: %s\n", p.ID) - fmt.Printf("Name: %s\n", p.Name) - fmt.Printf("Workdir: %s\n", wd) - if p.Description != "" { - fmt.Printf("Description: %s\n", p.Description) - } - if p.Persona != "" { - fmt.Printf("Persona: %s\n", p.Persona) - } - if len(p.Constraints) > 0 { - fmt.Println("Constraints:") - for _, c := range p.Constraints { - fmt.Printf(" - %s\n", c) - } - } - if p.Defaults != (projectx.Defaults{}) { - fmt.Println("Defaults:") - if p.Defaults.LLMProvider != "" { - fmt.Printf(" llm_provider: %s\n", p.Defaults.LLMProvider) - } - if p.Defaults.LLMModel != "" { - fmt.Printf(" llm_model: %s\n", p.Defaults.LLMModel) - } - if p.Defaults.ImageProvider != "" { - fmt.Printf(" image_provider: %s\n", p.Defaults.ImageProvider) - } - if p.Defaults.ImageModel != "" { - fmt.Printf(" image_model: %s\n", p.Defaults.ImageModel) - } - if p.Defaults.Locale != "" { - fmt.Printf(" locale: %s\n", p.Defaults.Locale) - } - } - if p.Settings != (projectx.Settings{}) { - fmt.Println("Settings:") - if p.Settings.BashPermissionMode != "" { - fmt.Printf(" bash_permission_mode: %s\n", p.Settings.EffectiveBashMode()) - } - if p.Settings.ReasoningEffort != "" { - fmt.Printf(" reasoning_effort: %s\n", p.Settings.EffectiveReasoningEffort()) - } - } - printKeySources(wd) - return nil -} - -// printKeySources writes a "Credentials:" block showing which provider -// keys are configured for this project, where they came from (project / -// global / none), and a masked value. -func printKeySources(wd string) { - providers := []string{"openrouter", "openai", "anthropic"} - type row struct{ provider, source, value string } - var rows []row - for _, p := range providers { - resolved := userconfig.ResolveProvider(wd, p) - if resolved.APIKey == "" { - continue - } - src := resolved.KeySource - if src == "" { - src = "unknown" - } - rows = append(rows, row{provider: p, source: src, value: maskKey(resolved.APIKey)}) - } - if len(rows) == 0 { - return - } - fmt.Println("Credentials:") - for _, r := range rows { - fmt.Printf(" %-11s %s (%s)\n", r.provider+":", r.source, r.value) - } -} - -func maskKey(k string) string { - if len(k) <= 8 { - return "•••" - } - return k[:4] + "…" + k[len(k)-4:] -} - -// runProjectSetKey is `openmelon project set-key []`. -// -// Without a provider arg, opens the interactive wizard (provider picker -// + masked key input). With a provider arg, skips the picker. -func runProjectSetKey(args []string) error { - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - hint := "" - if len(args) > 0 { - hint = args[0] - } - _, ok, err := onboard.RunProjectKeyWizard(wd, hint) - if err != nil { - return err - } - if !ok { - fmt.Fprintln(os.Stderr, "cancelled.") - } - return nil -} - -// runProjectUnsetKey is `openmelon project unset-key `. -func runProjectUnsetKey(args []string) error { - if len(args) != 1 { - return fmt.Errorf("usage: openmelon project unset-key ") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - creds, err := userconfig.LoadProjectCredentials(wd) - if err != nil { - return err - } - if _, had := creds.APIKeys[args[0]]; !had { - fmt.Printf("No project-scoped key set for %s (nothing to remove).\n", args[0]) - return nil - } - if err := userconfig.UnsetProjectAPIKey(wd, args[0]); err != nil { - return err - } - fmt.Printf("Removed project key for %s.\n", args[0]) - return nil -} - -// runProjectKeys is `openmelon project keys`. Lists what keys are -// configured for the current project (project + global, masked). -func runProjectKeys(_ []string) error { - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - providers := []string{"openrouter", "openai", "anthropic"} - any := false - for _, p := range providers { - k, src := userconfig.ResolveAPIKey(wd, p) - if src == userconfig.SourceNone { - fmt.Printf(" %-11s (none)\n", p+":") - continue - } - any = true - fmt.Printf(" %-11s %s (%s)\n", p+":", src, maskKey(k)) - } - if !any { - fmt.Fprintln(os.Stderr, "No API keys configured. Run `openmelon setup` (global) or `openmelon project set-key` (project-scoped).") - } - return nil -} - -// resolveProjectWorkdir returns (workdir, project, error). Resolution -// order: -// -// 1. -C in args (consumed if present at the front) -// 2. projectx.Discover(cwd) — walks up looking for .openmelon/ -// 3. userconfig.CurrentProject → workdir from registry -// -// Returns ErrNoCurrentProject if all three miss. -func resolveProjectWorkdir(args []string) (string, *projectx.Project, error) { - // Flag stripping: support a leading -C on subcommands that - // don't otherwise want to define their own -C. - if len(args) >= 2 && args[0] == "-C" { - wd, err := filepath.Abs(args[1]) - if err != nil { - return "", nil, err - } - p, err := projectx.Load(wd) - if err != nil { - return "", nil, err - } - return wd, p, nil - } - cwd, err := os.Getwd() - if err != nil { - return "", nil, err - } - if wd, err := projectx.Discover(cwd); err == nil && wd != "" { - p, err := projectx.Load(wd) - if err != nil { - return "", nil, err - } - return wd, p, nil - } - cfg, err := userconfig.LoadConfig() - if err != nil { - return "", nil, err - } - if cfg.CurrentProject == "" { - return "", nil, userconfig.ErrNoCurrentProject - } - entry, err := userconfig.Lookup(cfg.CurrentProject) - if err != nil { - return "", nil, err - } - p, err := projectx.Load(entry.Workdir) - if err != nil { - return "", nil, err - } - return entry.Workdir, p, nil -} diff --git a/cmd/openmelon/cmd_registry.go b/cmd/openmelon/cmd_registry.go deleted file mode 100644 index 7ec3b5f..0000000 --- a/cmd/openmelon/cmd_registry.go +++ /dev/null @@ -1,265 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - "text/tabwriter" - - "github.com/eight-acres-lab/openmelon/internal/registry" -) - -// stringSlice is a flag.Value that collects repeated string flags. -// -// Used by --tag (so the user can write `--tag a --tag b` instead of -// inventing a comma-separated DSL). -type stringSlice []string - -func (s *stringSlice) String() string { return strings.Join(*s, ",") } -func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil } - -// parseInterspersed parses args into fs while tolerating positional args -// before, between, or after flags. Stdlib flag.Parse stops at the first -// non-flag token; this wrapper hoists positionals to the end so flags -// after them are still parsed. -// -// The wrapper consults fs to distinguish bool flags (no following value) -// from valued flags. "--" is honored as an end-of-flags marker. -func parseInterspersed(fs *flag.FlagSet, args []string) error { - var positionals, hoisted []string - end := false - for i := 0; i < len(args); i++ { - a := args[i] - if end { - positionals = append(positionals, a) - continue - } - if a == "--" { - end = true - continue - } - if !strings.HasPrefix(a, "-") || a == "-" { - positionals = append(positionals, a) - continue - } - hoisted = append(hoisted, a) - // `--flag=value` carries its own value. - if strings.Contains(a, "=") { - continue - } - // Bool flags don't consume the next token. Look up the - // underlying flag to decide. - name := strings.TrimLeft(a, "-") - f := fs.Lookup(name) - if f != nil { - if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() { - continue - } - } - // Not a bool (or unknown — let flag.Parse error on it). Pull - // the next token along as the flag's value. - if i+1 < len(args) { - hoisted = append(hoisted, args[i+1]) - i++ - } - } - combined := append(hoisted, positionals...) - return fs.Parse(combined) -} - -// runCharacter dispatches `openmelon character `. -func runCharacter(args []string) error { - return runRegistryCmd(registry.KindCharacter, args) -} - -// runReference dispatches `openmelon reference `. -func runReference(args []string) error { - return runRegistryCmd(registry.KindReference, args) -} - -// runMaterial dispatches `openmelon material ` (no rm/show -// since materials are hash-addressed and largely opaque). -func runMaterial(args []string) error { - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "usage: openmelon material ...") - os.Exit(2) - } - switch args[0] { - case "add": - return runMaterialAdd(args[1:]) - case "list": - return runRegistryList(registry.KindMaterial, args[1:]) - default: - return fmt.Errorf("unknown material subcommand: %q", args[0]) - } -} - -func runRegistryCmd(kind registry.Kind, args []string) error { - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "usage: openmelon %s ...\n", kind) - os.Exit(2) - } - switch args[0] { - case "add": - return runRegistryAdd(kind, args[1:]) - case "list": - return runRegistryList(kind, args[1:]) - case "show": - return runRegistryShow(kind, args[1:]) - case "rm", "remove": - return runRegistryRm(kind, args[1:]) - default: - return fmt.Errorf("unknown %s subcommand: %q", kind, args[0]) - } -} - -func runRegistryAdd(kind registry.Kind, args []string) error { - imageFlagName := "portrait" - imageDestName := "portrait" - if kind == registry.KindReference { - imageFlagName = "image" - imageDestName = "image" - } - - fs := flag.NewFlagSet(string(kind)+" add", flag.ContinueOnError) - name := fs.String("name", "", "Human-readable name (default: slug)") - description := fs.String("description", "", "One- or two-sentence description; saved into .search") - image := fs.String(imageFlagName, "", "Path to a "+imageFlagName+" image to copy into the item dir") - update := fs.Bool("update", false, "Allow updating an existing item (default: error if it exists)") - var tags stringSlice - fs.Var(&tags, "tag", "Add a tag (repeatable). Tags must be kebab-case.") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 1 { - return fmt.Errorf("usage: openmelon %s add [--name ...] [--description ...] [--%s path] [--tag t]...", kind, imageFlagName) - } - slug := fs.Arg(0) - - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - item, err := registry.Add(wd, registry.AddOptions{ - Kind: kind, - Slug: slug, - Name: *name, - Description: *description, - Tags: tags, - ImagePath: *image, - ImageName: imageDestName, - AllowExists: *update, - }) - if err != nil { - return err - } - fmt.Printf("Added %s %s\n", kind, item.Slug) - if len(item.Images) > 0 { - fmt.Printf(" images: %s\n", strings.Join(item.Images, ", ")) - } - return nil -} - -func runRegistryList(kind registry.Kind, args []string) error { - fs := flag.NewFlagSet(string(kind)+" list", flag.ContinueOnError) - if err := parseInterspersed(fs, args); err != nil { - return err - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - items, err := registry.List(wd, kind) - if err != nil { - return err - } - if len(items) == 0 { - fmt.Printf("No %ss in this project.\n", kind) - return nil - } - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "SLUG\tNAME\tIMAGES\tTAGS\tDESCRIPTION") - for _, it := range items { - desc := it.Description - if len(desc) > 72 { - desc = desc[:72] + "…" - } - fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", - it.Slug, it.Name, len(it.Images), strings.Join(it.Tags, ","), desc) - } - return tw.Flush() -} - -func runRegistryShow(kind registry.Kind, args []string) error { - if len(args) != 1 { - return fmt.Errorf("usage: openmelon %s show ", kind) - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - item, err := registry.Get(wd, kind, args[0]) - if err != nil { - return err - } - fmt.Printf("Kind: %s\n", item.Kind) - fmt.Printf("Slug: %s\n", item.Slug) - fmt.Printf("Name: %s\n", item.Name) - if item.Description != "" { - fmt.Printf("Description: %s\n", item.Description) - } - if len(item.Tags) > 0 { - fmt.Printf("Tags: %s\n", strings.Join(item.Tags, ", ")) - } - if len(item.Images) > 0 { - fmt.Println("Images:") - for _, img := range item.Images { - fmt.Printf(" %s\n", img) - } - } - if len(item.Extra) > 0 { - fmt.Println("Metadata:") - for k, v := range item.Extra { - fmt.Printf(" %s: %s\n", k, v) - } - } - return nil -} - -func runRegistryRm(kind registry.Kind, args []string) error { - if len(args) != 1 { - return fmt.Errorf("usage: openmelon %s rm ", kind) - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - if err := registry.Remove(wd, kind, args[0]); err != nil { - return err - } - fmt.Printf("Removed %s %s\n", kind, args[0]) - return nil -} - -func runMaterialAdd(args []string) error { - fs := flag.NewFlagSet("material add", flag.ContinueOnError) - var tags stringSlice - fs.Var(&tags, "tag", "Add a tag (repeatable)") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() != 1 { - return fmt.Errorf("usage: openmelon material add [--tag t]...") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - item, err := registry.AddMaterial(wd, fs.Arg(0), tags) - if err != nil { - return err - } - fmt.Printf("Added material %s\n", item.Slug) - return nil -} diff --git a/cmd/openmelon/cmd_repl.go b/cmd/openmelon/cmd_repl.go deleted file mode 100644 index cadac7f..0000000 --- a/cmd/openmelon/cmd_repl.go +++ /dev/null @@ -1,379 +0,0 @@ -package main - -// cmd_repl.go — `openmelon repl` (and the no-args entry inside a -// project) launches the interactive read-eval-print loop. - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "golang.org/x/term" - - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/imagegen" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/onboard" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/repl" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/session" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/tools" - "github.com/eight-acres-lab/openmelon/internal/tui" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -func runRepl(_ []string) error { - // Onboarding: trust → auth → project init. Each step is a no-op - // when its precondition is already met. - res, err := onboard.Ensure() - if err != nil { - return err - } - if res.Quit { - return nil - } - wd := res.Workdir - proj, err := projectx.Load(wd) - if err != nil { - return err - } - // Best-effort retrofit of the .gitignore on existing projects so - // credentials.json and sessions/ are never accidentally committed. - // Non-fatal — a failure here shouldn't block the user from working. - if err := projectx.EnsureGitignore(wd); err != nil { - fmt.Fprintf(os.Stderr, "openmelon: warning: could not write .gitignore: %v\n", err) - } - - llmProvider, llmModel, imageProvider, imageModel := resolveDefaults(proj) - if llmProvider == "" { - llmProvider = "auto" - } - if imageProvider == "" { - imageProvider = "openrouter" - } - // Resolve provider config with project-overrides-global semantics. - apiKey := "" - llmBaseURL := "" - if llmProvider != "auto" { - resolved := userconfig.ResolveProvider(wd, llmProvider) - apiKey = resolved.APIKey - llmBaseURL = resolved.BaseURL - } - - llmClient, err := llm.New(llmProvider, apiKey, llmBaseURL, llmModel) - if err != nil { - switch { - case errors.Is(err, llm.ErrNoAPIKey): - return fmt.Errorf("no API key for %s — run `openmelon setup` to configure", llmProvider) - case errors.Is(err, llm.ErrModelRequired): - return fmt.Errorf("no LLM model — run `openmelon setup` to configure") - } - return fmt.Errorf("init LLM: %w", err) - } - tc, ok := llmClient.(llm.ToolCaller) - if !ok { - return fmt.Errorf("provider %q does not support tool calls — switch to --llm openai or --llm openrouter, or set defaults.llm_provider in project.json", llmClient.Provider()) - } - llmProvider = llmClient.Provider() - llmModel = llmClient.Model() - - var imgGen imagegen.Generator - if imageModel != "" { - imgResolved := userconfig.ResolveProvider(wd, imageProvider) - imgGen, err = imagegen.New(imageProvider, imgResolved.APIKey, imgResolved.BaseURL, imageModel) - if err != nil { - fmt.Fprintf(os.Stderr, "openmelon: image generation disabled (%v)\n", err) - } - } - - rt := &runtime.Runtime{ - LLM: tc, - MaxSteps: 24, - ReasoningEffort: resolveReasoningEffort(proj, llmProvider, llmModel), - } - - // rebuildToolsEnv composes a tools.Env from the current state and - // installs a fresh tools.Registry on rt. Called from WireSession - // (initial wire-up after the TUI creates the session) AND from - // the /model-image hot-swap closure below — both need the same - // "compose env, register, assign" sequence with whatever the - // latest imgGen + sessionDir are. - // - // approveHolder.fn is what tools.Env.Approve indirects through. - // allowedBinaries is the per-session "yes-always" set; both fields - // survive wireSession + /model-image rebuilds because env captures - // the holder by pointer. - var sessionDir string - var sessionOutputDir string - approveHolder := &struct { - fn func(req tools.ApprovalRequest) tools.ApprovalDecision - allowedBinaries map[string]bool - }{allowedBinaries: map[string]bool{}} - rebuildToolsEnv := func() { - reg := tools.NewRegistry() - tools.RegisterAll(reg, &tools.Env{ - Workdir: wd, - Project: proj, - SessionDir: sessionDir, - OutputDir: sessionOutputDir, - Compiler: &skillplus.Compiler{}, - ImageGen: imgGen, - Approve: func(req tools.ApprovalRequest) tools.ApprovalDecision { - if approveHolder.fn == nil { - return tools.ApprovalDecision{} - } - return approveHolder.fn(req) - }, - JudgeBash: tools.JudgeBashWithLLM(rt.LLM), - IsBashAllowed: func(binary string) bool { - return approveHolder.allowedBinaries[binary] - }, - AllowBash: func(binary string) { - approveHolder.allowedBinaries[binary] = true - }, - BashMode: string(proj.Settings.EffectiveBashMode()), - Hooks: rt.Hooks, - }) - rt.Registry = reg - } - wireSession := func(sd string) { - sessionDir = sd - sessionOutputDir = projectx.SessionOutputDir(wd, filepath.Base(sd)) - if rt.Hooks == nil { - rt.Hooks = hooks.NoopManager{} - } - rebuildToolsEnv() - } - - // Build a placeholder registry just to compute tool names for the - // system prompt; the real registry is rebuilt with SessionDir - // inside WireSession. - probe := tools.NewRegistry() - tools.RegisterAll(probe, &tools.Env{ - Workdir: wd, - Project: proj, - OutputDir: projectx.OutputDir(wd), - Compiler: &skillplus.Compiler{}, - ImageGen: imgGen, - }) - systemPrompt := buildProjectSystemPrompt(proj, probe.Names()) - - // Resume support: if `openmelon resume ` set resumeID, load - // that session's transcript so the new TUI starts pre-populated. - var resumedHistory []llm.Message - if resumeID != "" { - h, err := session.LoadHistory(wd, resumeID) - if err != nil { - return fmt.Errorf("resume: %w", err) - } - resumedHistory = h - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - intent := fmt.Sprintf("interactive REPL %s", time.Now().UTC().Format("2006-01-02 15:04")) - if resumeID != "" { - intent = fmt.Sprintf("resumed from %s · %s", resumeID, intent) - } - - llmTag := fmt.Sprintf("%s:%s", llmClient.Provider(), llmClient.Model()) - imageTag := "" - if imgGen != nil { - imageTag = fmt.Sprintf("%s:%s", imgGen.Provider(), imgGen.Model()) - } - // Hot-swap closures used by /model and /model-image. They rebuild - // the clients against the same provider config, swap them into the - // runtime, and persist the project defaults. - rebuildLLM := func(modelID string) (string, error) { - resolved := userconfig.ResolveProvider(wd, llmProvider) - c, err := llm.New(llmProvider, resolved.APIKey, resolved.BaseURL, modelID) - if err != nil { - return "", err - } - tc, ok := c.(llm.ToolCaller) - if !ok { - return "", fmt.Errorf("provider %q does not support tool calls", llmProvider) - } - rt.LLM = tc - rt.ReasoningEffort = resolveReasoningEffort(proj, llmProvider, modelID) - proj.Defaults.LLMProvider = llmProvider - proj.Defaults.LLMModel = modelID - if err := projectx.Save(wd, proj); err != nil { - return "", err - } - return fmt.Sprintf("%s:%s", llmProvider, modelID), nil - } - rebuildImageModel := func(provider, modelID string) (string, error) { - if provider == "" || modelID == "" { - imgGen = nil - rebuildToolsEnv() - proj.Defaults.ImageProvider = "" - proj.Defaults.ImageModel = "" - if err := projectx.Save(wd, proj); err != nil { - return "", err - } - return "", nil - } - resolved := userconfig.ResolveProvider(wd, provider) - g, err := imagegen.New(provider, resolved.APIKey, resolved.BaseURL, modelID) - if err != nil { - return "", err - } - imgGen = g - rebuildToolsEnv() - proj.Defaults.ImageProvider = provider - proj.Defaults.ImageModel = modelID - if err := projectx.Save(wd, proj); err != nil { - return "", err - } - return fmt.Sprintf("%s:%s", provider, modelID), nil - } - saveSettings := func(s projectx.Settings) error { - proj.Settings = s - rt.ReasoningEffort = s.EffectiveReasoningEffort() - if rt.ReasoningEffort == "" { - rt.ReasoningEffort = defaultReasoningEffort(llmProvider, llmModel) - } - if err := projectx.Save(wd, proj); err != nil { - return err - } - rebuildToolsEnv() - return nil - } - - useFullTUI := envBool("OPENMELON_TUI") && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) - if useFullTUI { - return tui.Run(ctx, tui.Options{ - Workdir: wd, - Project: proj, - Runtime: rt, - WireSession: wireSession, - SystemPrompt: systemPrompt, - SessionIntent: intent, - ResumedFrom: resumeID, - InitialHistory: resumedHistory, - LLMTag: llmTag, - ImageTag: imageTag, - Provider: llmProvider, - ImageProvider: imageProvider, - LLMModel: llmModel, - ImageModel: imageModel, - RebuildLLM: rebuildLLM, - RebuildImageModel: rebuildImageModel, - InstallApprove: func(approve func(req tools.ApprovalRequest) tools.ApprovalDecision) { - approveHolder.fn = approve - }, - BashMode: proj.Settings.EffectiveBashMode(), - ReasoningEffort: rt.ReasoningEffort, - SaveSettings: saveSettings, - }) - } - - return repl.Run(ctx, repl.Options{ - Workdir: wd, - Project: proj, - Runtime: rt, - WireSession: wireSession, - SystemPrompt: systemPrompt, - SessionIntent: intent, - ResumedFrom: resumeID, - InitialHistory: resumedHistory, - Provider: llmProvider, - Model: llmModel, - ModelTag: llmTag, - ImageProvider: imageProvider, - ImageModel: imageModel, - ImageTag: imageTag, - RebuildLLM: rebuildLLM, - RebuildImageModel: rebuildImageModel, - BashMode: proj.Settings.EffectiveBashMode(), - ReasoningEffort: rt.ReasoningEffort, - SaveSettings: saveSettings, - InstallApprove: func(approve func(req tools.ApprovalRequest) tools.ApprovalDecision) { - approveHolder.fn = approve - }, - }) -} - -func envBool(name string) bool { - raw := strings.TrimSpace(os.Getenv(name)) - if raw == "" { - return false - } - if b, err := strconv.ParseBool(raw); err == nil { - return b - } - switch strings.ToLower(raw) { - case "1", "yes", "y", "on": - return true - default: - return false - } -} - -// resolveDefaults reads model + provider preferences from the project, -// falling back to ~/.openmelon/config.json. -func resolveDefaults(p *projectx.Project) (llmProvider, llmModel, imageProvider, imageModel string) { - llmProvider = p.Defaults.LLMProvider - llmModel = p.Defaults.LLMModel - imageProvider = p.Defaults.ImageProvider - imageModel = p.Defaults.ImageModel - if cfg, _ := userconfig.LoadConfig(); cfg != nil { - if llmProvider == "" { - llmProvider = cfg.Defaults.LLMProvider - } - if llmModel == "" { - llmModel = cfg.Defaults.LLMModel - } - if imageProvider == "" { - imageProvider = cfg.Defaults.ImageProvider - } - if imageModel == "" { - imageModel = cfg.Defaults.ImageModel - } - } - return -} - -func resolveReasoningEffort(p *projectx.Project, provider, model string) string { - if p != nil { - if effort := p.Settings.EffectiveReasoningEffort(); effort != "" { - return effort - } - } - if cfg, _ := userconfig.LoadConfig(); cfg != nil { - if effort := normalizeReasoningEffort(cfg.Defaults.ReasoningEffort); effort != "" { - return effort - } - } - return defaultReasoningEffort(provider, model) -} - -func normalizeReasoningEffort(v string) string { - switch strings.ToLower(strings.TrimSpace(v)) { - case "none", "minimal", "low", "medium", "high", "xhigh": - return strings.ToLower(strings.TrimSpace(v)) - default: - return "" - } -} - -func defaultReasoningEffort(provider, model string) string { - p := strings.ToLower(strings.TrimSpace(provider)) - m := strings.ToLower(strings.TrimSpace(model)) - if p != "openai" && p != "openrouter" { - return "" - } - if strings.HasPrefix(m, "gpt-5") || strings.Contains(m, "/gpt-5") { - return "xhigh" - } - return "" -} diff --git a/cmd/openmelon/cmd_resume.go b/cmd/openmelon/cmd_resume.go deleted file mode 100644 index 227a008..0000000 --- a/cmd/openmelon/cmd_resume.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -// cmd_resume.go — `openmelon resume []`. Without an id, lists the -// most recent sessions for the current project so the user can copy- -// paste an id back. With an id, loads that session's messages.jsonl -// into the new TUI as starting history (a fresh session dir is opened -// to record the continuation, with `resumed_from` set in its meta). - -import ( - "fmt" - "os" - "strings" - "text/tabwriter" - "time" - - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/session" -) - -func runResume(args []string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - wd, err := projectx.Discover(cwd) - if err != nil { - return err - } - if wd == "" { - return fmt.Errorf("resume: not inside an openmelon project (cd into one or run `openmelon init`)") - } - - if len(args) == 0 { - // List + hint. - summaries, err := session.Recent(wd, 10) - if err != nil { - return err - } - if len(summaries) == 0 { - fmt.Println("No prior sessions in this project.") - return nil - } - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tWHEN\tTURNS\tFIRST") - for _, s := range summaries { - when := s.StartedAt.Local().Format("01-02 15:04") - first := strings.Join(strings.Fields(s.FirstUserMessage), " ") - if len(first) > 60 { - first = first[:60] + "…" - } - fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.ID, when, s.TurnCount, first) - } - _ = tw.Flush() - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Resume one with: openmelon resume ") - return nil - } - - // Verify the id exists before launching the TUI. - id := args[0] - if err := session.ValidateWorkspace(wd, id); err != nil { - return fmt.Errorf("resume: %w", err) - } - if _, err := session.LoadHistory(wd, id); err != nil { - return fmt.Errorf("resume: %w", err) - } - // Defer to runRepl with the resume id stashed in package-level - // state. cmd_repl reads it on entry. - resumeID = id - return runRepl(nil) -} - -// resumeID is set by runResume before runRepl is called. cmd_repl -// reads it on startup to load the prior history. Empty means "fresh -// session". Lives at package scope rather than threaded through every -// call site since `openmelon resume` is the only entry point that -// sets it. -var resumeID string - -// formatRelativeTime is a small helper for the picker output (unused -// today, kept for the future bubbletea picker). -func formatRelativeTime(t time.Time) string { - if t.IsZero() { - return "(unknown)" - } - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - default: - return fmt.Sprintf("%dd ago", int(d.Hours()/24)) - } -} diff --git a/cmd/openmelon/cmd_runtime_bridge.go b/cmd/openmelon/cmd_runtime_bridge.go deleted file mode 100644 index e0816f9..0000000 --- a/cmd/openmelon/cmd_runtime_bridge.go +++ /dev/null @@ -1,661 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/imagegen" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/session" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/tools" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -type bridgeRequest struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ID string `json:"id,omitempty"` - Approved bool `json:"approved,omitempty"` - Always bool `json:"always,omitempty"` -} - -type bridgeEvent struct { - Type string `json:"type"` - Kind string `json:"kind,omitempty"` - Text string `json:"text,omitempty"` - Status string `json:"status,omitempty"` - Activity string `json:"activity,omitempty"` - PromptTokens int `json:"promptTokens,omitempty"` - CompletionTokens int `json:"completionTokens,omitempty"` - TotalTokens int `json:"totalTokens,omitempty"` - SessionID string `json:"sessionId,omitempty"` - SessionDir string `json:"sessionDir,omitempty"` - Model string `json:"model,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - Project string `json:"project,omitempty"` - Provider string `json:"provider,omitempty"` - Error string `json:"error,omitempty"` - Detail map[string]any `json:"detail,omitempty"` -} - -type bridgeRuntime struct { - wd string - project *projectx.Project - rt *runtime.Runtime - session *session.Session - systemPrompt string - history []llm.Message - persisted int - imgGen imagegen.Generator - allowedBins map[string]bool - out *json.Encoder - outMu sync.Mutex - pendingMu sync.Mutex - pending []string - cancelMu sync.Mutex - cancel context.CancelFunc - running bool - approvalMu sync.Mutex - approvals map[string]chan tools.ApprovalDecision - approvalSeq int -} - -func runRuntimeBridge(args []string) error { - resume := "" - if len(args) > 0 { - resume = strings.TrimSpace(args[0]) - } - br, err := newBridgeRuntime(resume) - if err != nil { - enc := json.NewEncoder(os.Stdout) - msg := err.Error() - _ = enc.Encode(bridgeEvent{Type: "append", Kind: "error", Text: msg, Error: msg}) - _ = enc.Encode(bridgeEvent{Type: "status", Status: "error", Activity: "Runtime unavailable"}) - return runFailedBridgeLoop(enc, msg) - } - defer br.session.Close() - - br.emit(bridgeEvent{ - Type: "ready", - Status: "ready", - Activity: "Ready", - SessionID: br.session.ID, - SessionDir: br.session.Dir, - Model: br.rtModel(), - Reasoning: br.rt.ReasoningEffort, - Project: br.project.ID, - Provider: br.rtProvider(), - }) - - scanner := bufio.NewScanner(os.Stdin) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - var req bridgeRequest - if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { - br.emitError(fmt.Errorf("bad request: %w", err)) - continue - } - switch req.Type { - case "run": - text := strings.TrimSpace(req.Text) - if text == "" { - continue - } - if br.isRunning() { - br.addPending(text) - br.emit(bridgeEvent{Type: "append", Kind: "info", Text: "queued pending input"}) - continue - } - br.runTurn(text) - case "pending": - text := strings.TrimSpace(req.Text) - if text != "" { - br.addPending(text) - } - case "cancel": - br.cancelRun() - case "clear": - br.clearHistory() - case "history": - br.emitHistory() - case "save": - br.saveHistory(req.Text) - case "reload": - if err := br.reloadProjectRuntime(); err != nil { - br.emit(bridgeEvent{Type: "append", Kind: "error", Text: "reload: " + err.Error(), Error: err.Error()}) - br.emit(bridgeEvent{Type: "status", Status: "error", Activity: "Reload failed"}) - } - case "approval": - br.answerApproval(req.ID, tools.ApprovalDecision{Approved: req.Approved, Always: req.Always}) - case "shutdown": - br.cancelRun() - return nil - default: - br.emitError(fmt.Errorf("unknown request type %q", req.Type)) - } - } - return scanner.Err() -} - -func runFailedBridgeLoop(enc *json.Encoder, msg string) error { - scanner := bufio.NewScanner(os.Stdin) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - var req bridgeRequest - if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { - _ = enc.Encode(bridgeEvent{Type: "append", Kind: "error", Text: "bad request: " + err.Error(), Error: err.Error()}) - continue - } - if req.Type == "shutdown" { - return nil - } - _ = enc.Encode(bridgeEvent{Type: "append", Kind: "error", Text: msg, Error: msg}) - _ = enc.Encode(bridgeEvent{Type: "done"}) - } - return scanner.Err() -} - -func newBridgeRuntime(resume string) (*bridgeRuntime, error) { - cwd, err := os.Getwd() - if err != nil { - return nil, err - } - wd, err := projectx.Discover(cwd) - if err != nil { - return nil, err - } - if wd == "" { - return nil, errors.New("no openmelon project found — run `openmelon init` or `openmelon setup` first") - } - proj, err := projectx.Load(wd) - if err != nil { - return nil, err - } - if err := projectx.EnsureGitignore(wd); err != nil { - fmt.Fprintf(os.Stderr, "openmelon: warning: could not write .gitignore: %v\n", err) - } - - llmProvider, llmModel, imageProvider, imageModel := resolveDefaults(proj) - if llmProvider == "" { - llmProvider = "auto" - } - if imageProvider == "" { - imageProvider = "openrouter" - } - apiKey := "" - llmBaseURL := "" - if llmProvider != "auto" { - resolved := userconfig.ResolveProvider(wd, llmProvider) - apiKey = resolved.APIKey - llmBaseURL = resolved.BaseURL - } - llmClient, err := llm.New(llmProvider, apiKey, llmBaseURL, llmModel) - if err != nil { - switch { - case errors.Is(err, llm.ErrNoAPIKey): - return nil, fmt.Errorf("no API key for %s — run `openmelon setup` to configure", llmProvider) - case errors.Is(err, llm.ErrModelRequired): - return nil, fmt.Errorf("no LLM model — run `openmelon setup` to configure") - } - return nil, fmt.Errorf("init LLM: %w", err) - } - tc, ok := llmClient.(llm.ToolCaller) - if !ok { - return nil, fmt.Errorf("provider %q does not support tool calls", llmClient.Provider()) - } - llmProvider = llmClient.Provider() - llmModel = llmClient.Model() - - var imgGen imagegen.Generator - if imageModel != "" { - imgResolved := userconfig.ResolveProvider(wd, imageProvider) - imgGen, err = imagegen.New(imageProvider, imgResolved.APIKey, imgResolved.BaseURL, imageModel) - if err != nil { - fmt.Fprintf(os.Stderr, "openmelon: image generation disabled (%v)\n", err) - } - } - - rt := &runtime.Runtime{ - LLM: tc, - MaxSteps: 24, - ReasoningEffort: resolveReasoningEffort(proj, llmProvider, llmModel), - } - - intent := fmt.Sprintf("ts tui bridge %s", time.Now().UTC().Format("2006-01-02 15:04")) - if resume != "" { - intent = fmt.Sprintf("resumed from %s · %s", resume, intent) - } - sess, err := session.NewResume(wd, proj.ID, intent, resume) - if err != nil { - return nil, fmt.Errorf("bridge session: %w", err) - } - _ = sess.SetRuntimeInfo(llmProvider, llmModel) - rt.Hooks = hooks.ChainManagers(rt.Hooks, sess.HookRecorder()) - - sessionOutputDir := projectx.SessionOutputDir(wd, filepath.Base(sess.Dir)) - br := &bridgeRuntime{ - wd: wd, - project: proj, - rt: rt, - session: sess, - systemPrompt: "", - imgGen: imgGen, - allowedBins: map[string]bool{}, - out: json.NewEncoder(os.Stdout), - approvals: map[string]chan tools.ApprovalDecision{}, - } - br.rebuildToolsEnv(sessionOutputDir) - - probe := tools.NewRegistry() - tools.RegisterAll(probe, &tools.Env{ - Workdir: wd, - Project: proj, - OutputDir: projectx.OutputDir(wd), - Compiler: &skillplus.Compiler{}, - ImageGen: br.imgGen, - }) - - br.systemPrompt = buildProjectSystemPrompt(proj, probe.Names()) - rt.Tracer = (*bridgeTracer)(br) - rt.DrainUserInput = br.drainPending - if resume != "" { - h, err := session.LoadHistory(wd, resume) - if err != nil { - return nil, fmt.Errorf("resume: %w", err) - } - br.history = h - br.persisted = len(h) - } - return br, nil -} - -func (br *bridgeRuntime) runTurn(text string) { - ctx, cancel := context.WithCancel(context.Background()) - br.setRunning(cancel) - go func() { - defer br.setIdle() - res, err := br.rt.Run(ctx, runtime.RunInput{ - SystemPrompt: br.systemPrompt, - UserInput: text, - History: br.history, - }) - if err != nil { - br.emitError(err) - br.emit(bridgeEvent{Type: "status", Status: "error", Activity: "Error"}) - br.emit(bridgeEvent{Type: "done"}) - return - } - br.history = res.Messages - if br.persisted < len(br.history) { - _ = br.session.AppendMessages(br.history[br.persisted:]) - br.persisted = len(br.history) - } - _ = br.session.WriteSummary(res.FinishSummary, res.FinishArtifacts, res.Finished) - br.emit(bridgeEvent{Type: "status", Status: "ready", Activity: "Ready"}) - br.emit(bridgeEvent{Type: "done"}) - if next := br.takePendingJoined(); next != "" { - br.runTurn(next) - } - }() -} - -func (br *bridgeRuntime) emit(ev bridgeEvent) { - br.outMu.Lock() - defer br.outMu.Unlock() - _ = br.out.Encode(ev) -} - -func (br *bridgeRuntime) emitError(err error) { - br.emit(bridgeEvent{Type: "append", Kind: "error", Text: err.Error(), Error: err.Error()}) -} - -func (br *bridgeRuntime) rebuildToolsEnv(outputDir string) { - reg := tools.NewRegistry() - tools.RegisterAll(reg, &tools.Env{ - Workdir: br.wd, - Project: br.project, - SessionDir: br.session.Dir, - OutputDir: outputDir, - Compiler: &skillplus.Compiler{}, - ImageGen: br.imgGen, - Approve: func(req tools.ApprovalRequest) tools.ApprovalDecision { - return br.requestApproval(req) - }, - JudgeBash: tools.JudgeBashWithLLM(br.rt.LLM), - IsBashAllowed: func(binary string) bool { - return br.allowedBins[binary] - }, - AllowBash: func(binary string) { - br.allowedBins[binary] = true - }, - BashMode: string(br.project.Settings.EffectiveBashMode()), - Hooks: br.rt.Hooks, - }) - br.rt.Registry = reg -} - -func (br *bridgeRuntime) reloadProjectRuntime() error { - if br.isRunning() { - return errors.New("cannot reload while a turn is running") - } - proj, err := projectx.Load(br.wd) - if err != nil { - return err - } - llmProvider, llmModel, imageProvider, imageModel := resolveDefaults(proj) - if llmProvider == "" { - llmProvider = "auto" - } - if imageProvider == "" { - imageProvider = "openrouter" - } - - apiKey := "" - llmBaseURL := "" - if llmProvider != "auto" { - resolved := userconfig.ResolveProvider(br.wd, llmProvider) - apiKey = resolved.APIKey - llmBaseURL = resolved.BaseURL - } - llmClient, err := llm.New(llmProvider, apiKey, llmBaseURL, llmModel) - if err != nil { - return err - } - tc, ok := llmClient.(llm.ToolCaller) - if !ok { - return fmt.Errorf("provider %q does not support tool calls", llmClient.Provider()) - } - - var imgGen imagegen.Generator - if imageModel != "" { - imgResolved := userconfig.ResolveProvider(br.wd, imageProvider) - imgGen, err = imagegen.New(imageProvider, imgResolved.APIKey, imgResolved.BaseURL, imageModel) - if err != nil { - br.emit(bridgeEvent{Type: "append", Kind: "error", Text: "image generation disabled: " + err.Error()}) - } - } - - br.project = proj - br.imgGen = imgGen - br.rt.LLM = tc - br.rt.ReasoningEffort = resolveReasoningEffort(proj, llmClient.Provider(), llmClient.Model()) - br.rebuildToolsEnv(projectx.SessionOutputDir(br.wd, filepath.Base(br.session.Dir))) - - probe := tools.NewRegistry() - tools.RegisterAll(probe, &tools.Env{ - Workdir: br.wd, - Project: proj, - OutputDir: projectx.OutputDir(br.wd), - Compiler: &skillplus.Compiler{}, - ImageGen: br.imgGen, - }) - br.systemPrompt = buildProjectSystemPrompt(proj, probe.Names()) - _ = br.session.SetRuntimeInfo(llmClient.Provider(), llmClient.Model()) - br.emit(bridgeEvent{ - Type: "ready", - Status: "ready", - Activity: "Ready", - SessionID: br.session.ID, - SessionDir: br.session.Dir, - Model: llmClient.Model(), - Reasoning: br.rt.ReasoningEffort, - Project: br.project.ID, - Provider: llmClient.Provider(), - }) - return nil -} - -func (br *bridgeRuntime) clearHistory() { - br.history = nil - br.persisted = 0 - br.emit(bridgeEvent{Type: "append", Kind: "info", Text: "(history cleared)"}) -} - -func (br *bridgeRuntime) emitHistory() { - if len(br.history) == 0 { - br.emit(bridgeEvent{Type: "append", Kind: "info", Text: "(no conversation history)"}) - return - } - lines := make([]string, 0, len(br.history)) - for i, mm := range br.history { - label := string(mm.Role) - if len(mm.ToolCalls) > 0 { - label += " → tool_calls" - } - body := strings.ReplaceAll(mm.Content, "\n", " ") - if len(body) > 200 { - body = body[:200] + "…" - } - lines = append(lines, fmt.Sprintf(" [%d] %s: %s", i, label, body)) - } - br.emit(bridgeEvent{Type: "append", Kind: "info", Text: strings.Join(lines, "\n")}) -} - -func (br *bridgeRuntime) saveHistory(path string) { - path = strings.TrimSpace(path) - if path == "" { - br.emit(bridgeEvent{Type: "append", Kind: "error", Text: "/save: usage: /save "}) - return - } - f, err := os.Create(path) - if err != nil { - br.emit(bridgeEvent{Type: "append", Kind: "error", Text: "/save: " + err.Error()}) - return - } - enc := json.NewEncoder(f) - var saveErr error - for _, mm := range br.history { - if err := enc.Encode(mm); err != nil { - saveErr = err - break - } - } - if err := f.Close(); saveErr == nil { - saveErr = err - } - if saveErr != nil { - br.emit(bridgeEvent{Type: "append", Kind: "error", Text: "/save: " + saveErr.Error()}) - return - } - br.emit(bridgeEvent{Type: "append", Kind: "info", Text: fmt.Sprintf("saved %d messages → %s", len(br.history), path)}) -} - -func (br *bridgeRuntime) rtModel() string { - if c, ok := br.rt.LLM.(interface{ Model() string }); ok { - return c.Model() - } - return "" -} - -func (br *bridgeRuntime) rtProvider() string { - if c, ok := br.rt.LLM.(interface{ Provider() string }); ok { - return c.Provider() - } - return "" -} - -func (br *bridgeRuntime) addPending(text string) { - br.pendingMu.Lock() - defer br.pendingMu.Unlock() - br.pending = append(br.pending, text) -} - -func (br *bridgeRuntime) drainPending() []string { - br.pendingMu.Lock() - defer br.pendingMu.Unlock() - out := append([]string(nil), br.pending...) - br.pending = nil - return out -} - -func (br *bridgeRuntime) takePendingJoined() string { - pending := br.drainPending() - if len(pending) == 0 { - return "" - } - return strings.Join(pending, "\n\n") -} - -func (br *bridgeRuntime) setRunning(cancel context.CancelFunc) { - br.cancelMu.Lock() - defer br.cancelMu.Unlock() - br.cancel = cancel - br.running = true -} - -func (br *bridgeRuntime) setIdle() { - br.cancelMu.Lock() - defer br.cancelMu.Unlock() - br.cancel = nil - br.running = false -} - -func (br *bridgeRuntime) isRunning() bool { - br.cancelMu.Lock() - defer br.cancelMu.Unlock() - return br.running -} - -func (br *bridgeRuntime) cancelRun() { - br.cancelMu.Lock() - cancel := br.cancel - br.cancelMu.Unlock() - if cancel != nil { - cancel() - } -} - -func (br *bridgeRuntime) requestApproval(req tools.ApprovalRequest) tools.ApprovalDecision { - br.approvalMu.Lock() - br.approvalSeq++ - id := fmt.Sprintf("approval-%d", br.approvalSeq) - ch := make(chan tools.ApprovalDecision, 1) - br.approvals[id] = ch - br.approvalMu.Unlock() - - br.emit(bridgeEvent{ - Type: "approval", - Activity: "Approve " + req.Tool, - Detail: map[string]any{ - "id": id, - "tool": req.Tool, - "command": req.Command, - "description": req.Description, - "binary": req.Binary, - }, - }) - - select { - case decision := <-ch: - return decision - case <-time.After(10 * time.Minute): - br.answerApproval(id, tools.ApprovalDecision{}) - return tools.ApprovalDecision{} - } -} - -func (br *bridgeRuntime) answerApproval(id string, decision tools.ApprovalDecision) { - br.approvalMu.Lock() - ch := br.approvals[id] - delete(br.approvals, id) - br.approvalMu.Unlock() - if ch != nil { - ch <- decision - } -} - -type bridgeTracer bridgeRuntime - -func (t *bridgeTracer) br() *bridgeRuntime { return (*bridgeRuntime)(t) } - -func (t *bridgeTracer) OnTurnStart(turn int) { - t.br().emit(bridgeEvent{Type: "status", Status: "thinking", Activity: fmt.Sprintf("Thinking step %d", turn)}) -} - -func (t *bridgeTracer) OnText(delta string) { - t.br().emit(bridgeEvent{Type: "append", Kind: "assistant", Text: delta}) -} - -func (t *bridgeTracer) OnToolCall(call llm.ToolCall) { - if call.Name == "finish" { - return - } - t.br().emit(bridgeEvent{ - Type: "append", - Kind: "tool", - Text: fmt.Sprintf("● %s %s", call.Name, strings.TrimSpace(string(call.Arguments))), - Status: "tool", - Activity: "Calling " + call.Name, - }) - t.br().emit(bridgeEvent{Type: "status", Status: "tool", Activity: "Calling " + call.Name}) -} - -func (t *bridgeTracer) OnToolResult(call llm.ToolCall, content string, err error) { - if call.Name == "finish" { - t.br().emit(bridgeEvent{Type: "append", Kind: "assistant", Text: finishSummary(content)}) - return - } - kind := "result" - text := "└ " + compactToolResult(content) - if err != nil { - kind = "error" - text = "└ error: " + err.Error() - } - t.br().emit(bridgeEvent{Type: "append", Kind: kind, Text: text}) -} - -func (t *bridgeTracer) OnTurnEnd(_ int, _ llm.FinishReason, usage llm.Usage) { - t.br().emit(bridgeEvent{ - Type: "usage", - PromptTokens: usage.PromptTokens, - CompletionTokens: usage.CompletionTokens, - TotalTokens: usage.TotalTokens, - }) -} - -func compactToolResult(content string) string { - var obj map[string]any - if err := json.Unmarshal([]byte(content), &obj); err != nil { - return truncateBridge(content, 220) - } - if raw, ok := obj["error"]; ok { - return fmt.Sprintf("error: %v", raw) - } - for _, key := range []string{"path", "file", "output", "summary", "status"} { - if v, ok := obj[key]; ok { - return truncateBridge(fmt.Sprintf("%s: %v", key, v), 220) - } - } - return truncateBridge(content, 220) -} - -func finishSummary(content string) string { - var obj map[string]any - if err := json.Unmarshal([]byte(content), &obj); err != nil { - return content - } - if s, _ := obj["summary"].(string); strings.TrimSpace(s) != "" { - return s - } - return content -} - -func truncateBridge(s string, n int) string { - s = strings.Join(strings.Fields(s), " ") - if len(s) <= n { - return s - } - return s[:n] + "…" -} diff --git a/cmd/openmelon/cmd_search.go b/cmd/openmelon/cmd_search.go deleted file mode 100644 index 3287157..0000000 --- a/cmd/openmelon/cmd_search.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - "text/tabwriter" - - "github.com/eight-acres-lab/openmelon/internal/search" -) - -// runSearch is `openmelon search ...`. -// -// All non-flag args are joined with spaces and parsed as a search query. -// See package search for the query language. -func runSearch(args []string) error { - fs := flag.NewFlagSet("search", flag.ContinueOnError) - limit := fs.Int("limit", 50, "Cap result count (default 50)") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() == 0 { - return fmt.Errorf("usage: openmelon search ... — supports tag:foo, kind:character, -negative, \"quoted phrase\"") - } - q, err := search.Parse(strings.Join(fs.Args(), " ")) - if err != nil { - return err - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - hits, err := search.Run(wd, q) - if err != nil { - return err - } - if len(hits) == 0 { - fmt.Println("No matches.") - return nil - } - if *limit > 0 && len(hits) > *limit { - hits = hits[:*limit] - } - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "SCORE\tKIND\tSLUG\tNAME\tDESCRIPTION") - for _, h := range hits { - desc := h.Item.Description - if len(desc) > 72 { - desc = desc[:72] + "…" - } - fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", - h.Score, h.Item.Kind, h.Item.Slug, h.Item.Name, desc) - } - return tw.Flush() -} diff --git a/cmd/openmelon/cmd_session.go b/cmd/openmelon/cmd_session.go deleted file mode 100644 index c8a214a..0000000 --- a/cmd/openmelon/cmd_session.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "text/tabwriter" - - "github.com/eight-acres-lab/openmelon/internal/session" -) - -func runSession(args []string) error { - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "usage: openmelon session ...") - os.Exit(2) - } - switch args[0] { - case "events": - return runSessionEvents(args[1:]) - default: - return fmt.Errorf("unknown session subcommand: %q", args[0]) - } -} - -func runSessionEvents(args []string) error { - fs := flag.NewFlagSet("session events", flag.ContinueOnError) - limit := fs.Int("n", 50, "Number of recent events") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() != 1 { - return fmt.Errorf("usage: openmelon session events [-n 50]") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - events, err := session.LoadEvents(wd, fs.Arg(0), *limit) - if err != nil { - return err - } - if len(events) == 0 { - fmt.Println("No events recorded for this session.") - return nil - } - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "TIME\tTYPE\tSTEP\tTOOL\tSPACE\tSTATUS") - for _, e := range events { - fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\t%s\n", - e.At.Local().Format("01-02 15:04:05"), - e.Type, - e.Step, - e.Tool, - e.SpaceID, - e.Status, - ) - } - return tw.Flush() -} diff --git a/cmd/openmelon/cmd_setup.go b/cmd/openmelon/cmd_setup.go deleted file mode 100644 index 1d73787..0000000 --- a/cmd/openmelon/cmd_setup.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -// cmd_setup.go — `openmelon setup` re-runs the auth wizard so users can -// rotate keys or switch providers without editing files. - -import ( - "fmt" - "os" - - "github.com/eight-acres-lab/openmelon/internal/onboard" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -func runSetup(_ []string) error { - // Force the wizard to run by clearing existing credentials in - // memory before calling EnsureAuth — but persist nothing until the - // user finishes the flow successfully. - creds, err := userconfig.LoadCredentials() - if err != nil { - return err - } - had := len(creds.APIKeys) > 0 - if had { - // Stash and clear so EnsureAuth's "already configured?" check - // fires the wizard. We restore on user cancel. - empty := &userconfig.Credentials{APIKeys: map[string]string{}} - if err := userconfig.SaveCredentials(empty); err != nil { - return err - } - } - configured, err := onboard.EnsureAuth() - if err != nil { - // Restore on error. - if had { - _ = userconfig.SaveCredentials(creds) - } - return err - } - if !configured { - // User cancelled — restore. - if had { - _ = userconfig.SaveCredentials(creds) - fmt.Fprintln(os.Stderr, "setup cancelled; previous credentials kept") - } - return nil - } - fmt.Fprintln(os.Stderr, "setup complete.") - return nil -} diff --git a/cmd/openmelon/cmd_space.go b/cmd/openmelon/cmd_space.go deleted file mode 100644 index a1fab06..0000000 --- a/cmd/openmelon/cmd_space.go +++ /dev/null @@ -1,515 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "strings" - "text/tabwriter" - - "github.com/eight-acres-lab/openmelon/internal/continuity" -) - -// runSpace dispatches `openmelon space `. -func runSpace(args []string) error { - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "usage: openmelon space ...") - os.Exit(2) - } - switch args[0] { - case "create": - return runSpaceCreate(args[1:]) - case "activate": - return runSpaceActivate(args[1:]) - case "list": - return runSpaceList(args[1:]) - case "show": - return runSpaceShow(args[1:]) - case "context": - return runSpaceContext(args[1:]) - case "search": - return runSpaceSearch(args[1:]) - case "decision": - return runSpaceDecision(args[1:]) - case "feedback": - return runSpaceFeedback(args[1:]) - case "memory": - return runSpaceMemory(args[1:]) - case "promote": - return runSpacePromote(args[1:]) - case "episode": - return runSpaceEpisode(args[1:]) - case "asset": - return runSpaceAsset(args[1:]) - case "asset-weight": - return runSpaceAssetWeight(args[1:]) - case "compact": - return runSpaceCompact(args[1:]) - default: - return fmt.Errorf("unknown space subcommand: %q", args[0]) - } -} - -func runSpaceCreate(args []string) error { - fs := flag.NewFlagSet("space create", flag.ContinueOnError) - name := fs.String("name", "", "Human-readable name (default: id)") - platform := fs.String("platform", "", "Target platform, e.g. short-video") - audience := fs.String("audience", "", "Target audience") - description := fs.String("description", "", "One-line space description") - assumptions := fs.String("assumptions", "", "Provisional assumptions markdown") - var tags stringSlice - fs.Var(&tags, "tag", "Add a tag (repeatable)") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() != 1 { - return fmt.Errorf("usage: openmelon space create [--name ...] [--description ...] [--tag t]...") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - sp, err := continuity.CreateSpace(wd, continuity.CreateSpaceOptions{ - ID: fs.Arg(0), - Name: *name, - Platform: *platform, - Audience: *audience, - Description: *description, - Tags: tags, - Assumptions: *assumptions, - }) - if err != nil { - return err - } - fmt.Printf("Created space %s\n", sp.ID) - fmt.Printf(" dir: %s\n", continuity.SpaceDir(wd, sp.ID)) - return nil -} - -func runSpaceActivate(args []string) error { - fs := flag.NewFlagSet("space activate", flag.ContinueOnError) - reason := fs.String("reason", "", "Why this direction was confirmed") - weight := fs.Float64("weight", 1.0, "Decision weight") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 2 { - return fmt.Errorf("usage: openmelon space activate ") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - sp, d, err := continuity.ActivateSpace(wd, fs.Arg(0), continuity.Decision{ - Decision: strings.Join(fs.Args()[1:], " "), - Reason: *reason, - Weight: *weight, - }) - if err != nil { - return err - } - fmt.Printf("Activated space %s with decision %s\n", sp.ID, d.ID) - return nil -} - -func runSpaceList(args []string) error { - fs := flag.NewFlagSet("space list", flag.ContinueOnError) - if err := parseInterspersed(fs, args); err != nil { - return err - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - spaces, err := continuity.ListSpaces(wd) - if err != nil { - return err - } - if len(spaces) == 0 { - fmt.Println("No creative spaces in this project.") - return nil - } - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tTAGS\tDESCRIPTION") - for _, sp := range spaces { - desc := sp.Description - if len(desc) > 72 { - desc = desc[:72] + "..." - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", sp.ID, sp.Name, sp.Status, strings.Join(sp.Tags, ","), desc) - } - return tw.Flush() -} - -func runSpaceShow(args []string) error { - fs := flag.NewFlagSet("space show", flag.ContinueOnError) - jsonOut := fs.Bool("json", false, "Print full context packet as JSON") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() != 1 { - return fmt.Errorf("usage: openmelon space show [--json]") - } - wd, proj, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - p, err := continuity.BuildContextPacket(wd, proj.ID, fs.Arg(0)) - if err != nil { - return err - } - if *jsonOut { - b, _ := json.MarshalIndent(p, "", " ") - fmt.Println(string(b)) - return nil - } - fmt.Printf("ID: %s\n", p.Space.ID) - fmt.Printf("Name: %s\n", p.Space.Name) - if p.Space.Platform != "" { - fmt.Printf("Platform: %s\n", p.Space.Platform) - } - if p.Space.Audience != "" { - fmt.Printf("Audience: %s\n", p.Space.Audience) - } - if p.Space.Description != "" { - fmt.Printf("Description: %s\n", p.Space.Description) - } - if len(p.Space.Tags) > 0 { - fmt.Printf("Tags: %s\n", strings.Join(p.Space.Tags, ", ")) - } - if strings.TrimSpace(p.Assumptions) != "" { - fmt.Println("\nAssumptions:") - fmt.Print(p.Assumptions) - } - if strings.TrimSpace(p.Canon) != "" { - fmt.Println("\nCanon:") - fmt.Print(p.Canon) - } - fmt.Printf("\nRecent: %d decisions, %d feedback items, %d episodes, %d assets\n", - len(p.RecentDecisions), len(p.RecentFeedback), len(p.RecentEpisodes), len(p.Assets)) - return nil -} - -func runSpaceContext(args []string) error { - fs := flag.NewFlagSet("space context", flag.ContinueOnError) - query := fs.String("query", "", "Current creative intent for ranking") - maxDecisions := fs.Int("max-decisions", 8, "Decision budget") - maxFeedback := fs.Int("max-feedback", 8, "Feedback budget") - maxEpisodes := fs.Int("max-episodes", 8, "Episode budget") - maxAssets := fs.Int("max-assets", 20, "Asset budget") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() != 1 { - return fmt.Errorf("usage: openmelon space context [--query ...] [--max-assets n] [--max-decisions n]") - } - wd, proj, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - p, err := continuity.BuildSelectedContextPacket(wd, proj.ID, fs.Arg(0), continuity.SelectionOptions{ - Query: *query, - MaxDecisions: *maxDecisions, - MaxFeedback: *maxFeedback, - MaxEpisodes: *maxEpisodes, - MaxAssets: *maxAssets, - }) - if err != nil { - return err - } - b, _ := json.MarshalIndent(p, "", " ") - fmt.Println(string(b)) - return nil -} - -func runSpaceSearch(args []string) error { - fs := flag.NewFlagSet("space search", flag.ContinueOnError) - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() == 0 { - return fmt.Errorf("usage: openmelon space search ...") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - hits, err := continuity.SearchSpaces(wd, strings.Join(fs.Args(), " ")) - if err != nil { - return err - } - if len(hits) == 0 { - fmt.Println("No matches.") - return nil - } - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "SCORE\tID\tNAME\tDESCRIPTION") - for _, h := range hits { - desc := h.Space.Description - if len(desc) > 72 { - desc = desc[:72] + "..." - } - fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", h.Score, h.Space.ID, h.Space.Name, desc) - } - return tw.Flush() -} - -func runSpaceDecision(args []string) error { - fs := flag.NewFlagSet("space decision", flag.ContinueOnError) - scope := fs.String("scope", "space", "Decision scope") - target := fs.String("target", "", "Decision target") - reason := fs.String("reason", "", "Why this decision was made") - weight := fs.Float64("weight", 1.0, "Decision weight") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 2 { - return fmt.Errorf("usage: openmelon space decision ") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - d, err := continuity.RecordDecision(wd, fs.Arg(0), continuity.Decision{ - Scope: *scope, - Target: *target, - Decision: strings.Join(fs.Args()[1:], " "), - Reason: *reason, - Weight: *weight, - }) - if err != nil { - return err - } - fmt.Printf("Recorded decision %s\n", d.ID) - return nil -} - -func runSpaceFeedback(args []string) error { - fs := flag.NewFlagSet("space feedback", flag.ContinueOnError) - episode := fs.String("episode", "", "Related episode id") - source := fs.String("source", "user", "Feedback source") - evidence := fs.String("evidence", "", "Evidence") - recommendation := fs.String("recommendation", "", "Recommended strategy change") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 2 { - return fmt.Errorf("usage: openmelon space feedback [--evidence ...] [--recommendation ...]") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - f, err := continuity.RecordFeedback(wd, fs.Arg(0), continuity.Feedback{ - EpisodeID: *episode, - Source: *source, - Signal: fs.Arg(1), - Evidence: *evidence, - Recommendation: *recommendation, - }) - if err != nil { - return err - } - fmt.Printf("Recorded feedback %s\n", f.ID) - return nil -} - -func runSpaceMemory(args []string) error { - fs := flag.NewFlagSet("space memory", flag.ContinueOnError) - id := fs.String("id", "", "Memory item id") - kind := fs.String("kind", "observation", "Memory kind") - scope := fs.String("scope", "", "Scope") - target := fs.String("target", "", "Target") - source := fs.String("source", "user", "Source") - weight := fs.Float64("weight", 0.5, "Memory weight") - status := fs.String("status", "provisional", "Status") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 2 { - return fmt.Errorf("usage: openmelon space memory [--id mem-x] [--kind observation]") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - item, err := continuity.RecordMemoryItem(wd, fs.Arg(0), continuity.MemoryItem{ - ID: *id, - Kind: *kind, - Scope: *scope, - Target: *target, - Content: strings.Join(fs.Args()[1:], " "), - Source: *source, - Weight: *weight, - Status: *status, - }) - if err != nil { - return err - } - fmt.Printf("Recorded memory item %s\n", item.ID) - return nil -} - -func runSpacePromote(args []string) error { - fs := flag.NewFlagSet("space promote", flag.ContinueOnError) - reason := fs.String("reason", "", "Why this memory is confirmed") - target := fs.String("target", "", "Decision target") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 3 { - return fmt.Errorf("usage: openmelon space promote ") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - d, err := continuity.PromoteMemoryItem(wd, fs.Arg(0), continuity.MemoryPromotion{ - ItemID: fs.Arg(1), - Decision: strings.Join(fs.Args()[2:], " "), - Reason: *reason, - Target: *target, - }) - if err != nil { - return err - } - fmt.Printf("Promoted memory into decision %s\n", d.ID) - return nil -} - -func runSpaceEpisode(args []string) error { - fs := flag.NewFlagSet("space episode", flag.ContinueOnError) - id := fs.String("id", "", "Episode id (default slug from topic/title)") - title := fs.String("title", "", "Episode title") - status := fs.String("status", "draft", "Episode status") - brief := fs.String("brief", "", "Episode brief markdown") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 2 { - return fmt.Errorf("usage: openmelon space episode [--id ...] [--brief ...]") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - ep, err := continuity.CreateEpisode(wd, fs.Arg(0), continuity.Episode{ - ID: *id, - Title: *title, - Topic: strings.Join(fs.Args()[1:], " "), - Status: *status, - Brief: *brief, - }) - if err != nil { - return err - } - fmt.Printf("Created episode %s\n", ep.ID) - return nil -} - -func runSpaceAsset(args []string) error { - fs := flag.NewFlagSet("space asset", flag.ContinueOnError) - id := fs.String("id", "", "Asset id (default slug from description/kind)") - kind := fs.String("kind", "", "Asset kind, e.g. background, character, typography, prompt") - status := fs.String("status", "active", "Asset status") - reuse := fs.String("reuse", "", "Reuse policy") - weight := fs.Float64("weight", 1.0, "Asset weight") - var files stringSlice - var tags stringSlice - fs.Var(&files, "file", "Related file path (repeatable)") - fs.Var(&tags, "tag", "Tag (repeatable)") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() < 2 { - return fmt.Errorf("usage: openmelon space asset [--kind ...] [--file path]...") - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - a, err := continuity.RegisterAsset(wd, fs.Arg(0), continuity.Asset{ - ID: *id, - Kind: *kind, - Status: *status, - Description: strings.Join(fs.Args()[1:], " "), - ReusePolicy: *reuse, - Files: files, - Tags: tags, - Weight: *weight, - }) - if err != nil { - return err - } - fmt.Printf("Registered asset %s\n", a.ID) - return nil -} - -func runSpaceAssetWeight(args []string) error { - fs := flag.NewFlagSet("space asset-weight", flag.ContinueOnError) - status := fs.String("status", "", "Optional new status") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() != 3 { - return fmt.Errorf("usage: openmelon space asset-weight [--status archived]") - } - var weight float64 - if _, err := fmt.Sscanf(fs.Arg(2), "%f", &weight); err != nil { - return fmt.Errorf("asset-weight: invalid weight %q", fs.Arg(2)) - } - wd, _, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - a, err := continuity.UpdateAssetWeight(wd, fs.Arg(0), fs.Arg(1), weight, *status) - if err != nil { - return err - } - fmt.Printf("Updated asset %s weight to %.2f", a.ID, a.Weight) - if a.Status != "" { - fmt.Printf(" (%s)", a.Status) - } - fmt.Println() - return nil -} - -func runSpaceCompact(args []string) error { - fs := flag.NewFlagSet("space compact", flag.ContinueOnError) - draft := fs.Bool("draft", false, "Print a compaction draft instead of recording it") - summary := fs.String("summary", "", "Compaction summary to record") - scope := fs.String("scope", "space", "Compaction scope") - if err := parseInterspersed(fs, args); err != nil { - return err - } - if fs.NArg() != 1 { - return fmt.Errorf("usage: openmelon space compact [--draft | --summary ...]") - } - wd, proj, err := resolveProjectWorkdir(nil) - if err != nil { - return err - } - if *draft || strings.TrimSpace(*summary) == "" { - body, err := continuity.BuildCompactionDraft(wd, proj.ID, fs.Arg(0)) - if err != nil { - return err - } - if *draft || strings.TrimSpace(*summary) == "" { - fmt.Print(body) - if *draft { - return nil - } - return fmt.Errorf("space compact: pass --summary to record a compaction, or --draft to only print the draft") - } - } - c, err := continuity.RecordSpaceCompaction(wd, fs.Arg(0), continuity.SpaceCompaction{ - Summary: *summary, - Scope: *scope, - }) - if err != nil { - return err - } - fmt.Printf("Recorded compaction %s\n", c.ID) - return nil -} diff --git a/cmd/openmelon/main.go b/cmd/openmelon/main.go deleted file mode 100644 index 6f4b1a7..0000000 --- a/cmd/openmelon/main.go +++ /dev/null @@ -1,478 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/agent" - "github.com/eight-acres-lab/openmelon/internal/generation" - "github.com/eight-acres-lab/openmelon/internal/imagegen" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/project" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/userconfig" - "github.com/eight-acres-lab/openmelon/internal/workflow" -) - -// openmelon dispatches between two execution modes: -// -// - Agent (the new primary, 0.2): triggered by -p "". Runs the -// one-shot agent loop — compile a Skill-Plus package, send to LLM, -// optionally generate image, save artifacts + provenance. -// -// - Workflow (the legacy 0.1 entry, kept for backward compatibility): -// triggered by --project . Runs the declarative -// workflow engine. -// -// Future modes (REPL, MCP server, HTTP serve) become subcommands once -// the surface stabilizes. - -// subcommands is the set of recognized first-arg subcommand names. -// Anything else (including "-p" / "--project") falls through to the -// legacy flag-based dispatcher below for backward compatibility. -var subcommands = map[string]func(args []string) error{ - "init": runInit, - "project": runProject, - "character": runCharacter, - "reference": runReference, - "material": runMaterial, - "search": runSearch, - "space": runSpace, - "repl": runRepl, - "setup": runSetup, - "resume": runResume, - "session": runSession, - "runtime-bridge": runRuntimeBridge, -} - -func main() { - if len(os.Args) >= 2 { - if fn, ok := subcommands[os.Args[1]]; ok { - if err := fn(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "openmelon: %v\n", err) - os.Exit(1) - } - return - } - if os.Args[1] == "help" || os.Args[1] == "-h" || os.Args[1] == "--help" { - printHelp() - return - } - } - - // No args → enter the REPL. The REPL's own onboarding handles - // trust + auth + project init when those aren't set up yet, so - // even fresh users with no project go through a guided flow. - if len(os.Args) == 1 { - if err := runRepl(nil); err != nil { - fmt.Fprintf(os.Stderr, "openmelon: %v\n", err) - os.Exit(1) - } - return - } - - fs := flag.NewFlagSet("openmelon", flag.ExitOnError) - - // Agent-mode flags (0.2). - prompt := fs.String("p", "", "One-shot intent (triggers agent mode)") - skillSpec := fs.String("skill", "skillplus:food-street-realism", "Skill spec: skillplus:, path:, or a bare path") - llmProvider := fs.String("llm", "", "LLM provider (auto|anthropic|openai|openrouter). Agent mode: empty = auto. Workflow mode: activates LLMProvider when --generate=true; requires --llm-model.") - llmModel := fs.String("llm-model", "", "Override LLM default model") - llmBaseURL := fs.String("llm-base-url", "", "Override LLM base URL — useful for proxies / relays. Default reads OPENAI_BASE_URL or OPENROUTER_BASE_URL env per provider.") - imgEnabled := fs.Bool("image", true, "Generate an image from the structured generation_prompt") - imgProvider := fs.String("image-provider", "openai", "Image generator provider (openai|openrouter)") - imgModel := fs.String("image-model", "", "Image model id — required when --image is true (e.g. gpt-image-1, dall-e-3, openai/gpt-5-image, google/gemini-2.5-flash-image)") - imgBaseURL := fs.String("image-base-url", "", "Override image API base URL. Default reads OPENAI_BASE_URL or OPENROUTER_BASE_URL based on --image-provider.") - imgSize := fs.String("image-size", "", "Image size as WxH (e.g. 1024x1024, 1792x1024). Empty → vendor default.") - publish := fs.String("publish", "", "Publish the result after generation: vbox (requires vbox-cli on PATH and VBOX_API_KEY)") - postText := fs.String("post-text", "", "Override post text when publishing (default: the user's intent)") - skillRoot := fs.String("skill-root", "", "Directory under which skillplus: resolves to /examples/.skillplus (also: $SKILLPLUS_EXAMPLES_ROOT)") - - // Workflow-mode flags (0.1, legacy). - projectFlag := fs.String("project", "", "Path to project.json (workflow mode)") - workflowFlag := fs.String("workflow", "", "Workflow ID (workflow mode)") - intentFlag := fs.String("intent", "", "Intent for workflow mode (deprecated, use -p)") - doGenerate := fs.Bool("generate", false, "Workflow mode: execute generation step (requires --generate-cmd)") - generateCmd := fs.String("generate-cmd", "", "Workflow mode: shell command for generation") - - // Shared flags. - artifactDir := fs.String("artifact-dir", "outputs/artifacts", "Visible output directory for artifacts + provenance") - compilerPath := fs.String("compiler", "", "PYTHONPATH for editable Skill-Plus compiler (default: prefer `skillplus` console script on PATH)") - timeoutSec := fs.Int("timeout", 300, "Total execution timeout in seconds") - locale := fs.String("locale", "zh-CN", "Locale for skill compilation") - modelProfile := fs.String("model-profile", "gpt-image-family", "Skill compile model profile") - provenancePath := fs.String("provenance", "", "Override provenance JSONL path (default: /provenance.jsonl)") - jsonOut := fs.Bool("json", false, "Print final result as JSON to stdout (agent mode)") - - if err := fs.Parse(os.Args[1:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSec)*time.Second) - defer cancel() - - switch { - case *prompt != "": - err := runAgent(ctx, agentOpts{ - intent: *prompt, - skillSpec: *skillSpec, - llmProvider: *llmProvider, - llmModel: *llmModel, - llmBaseURL: *llmBaseURL, - imageEnabled: *imgEnabled, - imageProvider: *imgProvider, - imageModel: *imgModel, - imageBaseURL: *imgBaseURL, - imageSize: *imgSize, - publish: *publish, - postText: *postText, - locale: *locale, - modelProfile: *modelProfile, - compilerPath: *compilerPath, - artifactDir: *artifactDir, - skillRoot: *skillRoot, - jsonOut: *jsonOut, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "[openmelon] error: %v\n", err) - os.Exit(1) - } - case *projectFlag != "": - err := runWorkflow(ctx, workflowOpts{ - projectPath: *projectFlag, - workflowID: *workflowFlag, - intent: *intentFlag, - artifactDir: *artifactDir, - compilerPath: compilerPathOrDefault(*compilerPath), - doGenerate: *doGenerate, - generateCmd: *generateCmd, - llmProvider: *llmProvider, - llmModel: *llmModel, - provenancePath: *provenancePath, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "[openmelon] error: %v\n", err) - os.Exit(1) - } - default: - printHelp() - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Agent / workflow flags:") - fs.PrintDefaults() - os.Exit(1) - } -} - -// printHelp writes the top-level usage block to stderr. -func printHelp() { - fmt.Fprintln(os.Stderr, "openmelon — content-creation agent for the terminal") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Interactive (in a project):") - fmt.Fprintln(os.Stderr, " openmelon Enter the REPL") - fmt.Fprintln(os.Stderr, " openmelon repl Same; explicit form") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Subcommands:") - fmt.Fprintln(os.Stderr, " init [] Set up cwd as an openmelon project") - fmt.Fprintln(os.Stderr, " project list|use|show Manage / inspect projects") - fmt.Fprintln(os.Stderr, " character add|list|show|rm Project character library") - fmt.Fprintln(os.Stderr, " reference add|list|show|rm Project reference-image library") - fmt.Fprintln(os.Stderr, " material add|list Hash-addressed material pool") - fmt.Fprintln(os.Stderr, " space create|list|show|context Creative continuity spaces") - fmt.Fprintln(os.Stderr, " session events Inspect session lifecycle events") - fmt.Fprintln(os.Stderr, ` search "" Grep across the project libraries`) - fmt.Fprintln(os.Stderr, " runtime-bridge JSONL bridge for the TS TUI") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "One-shot generation:") - fmt.Fprintln(os.Stderr, ` openmelon -p "" [--skill skillplus:] [--publish vbox]`) - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Legacy declarative workflow mode:") - fmt.Fprintln(os.Stderr, " openmelon --project examples/food-exploration/project.json") -} - -// compilerPathOrDefault preserves the workflow-mode legacy default for -// backward compatibility with existing `openmelon --project ...` invocations -// that did not pass --compiler. -func compilerPathOrDefault(p string) string { - if p != "" { - return p - } - return "../skillplus/src" -} - -// ===================================================================== -// Agent mode (0.2) -// ===================================================================== - -type agentOpts struct { - intent string - skillSpec string - llmProvider string - llmModel string - llmBaseURL string - imageEnabled bool - imageProvider string - imageModel string - imageBaseURL string - imageSize string - publish string - postText string - locale string - modelProfile string - compilerPath string - artifactDir string - skillRoot string - jsonOut bool -} - -func runAgent(ctx context.Context, opts agentOpts) error { - // If we're inside a project, use the tool-driven runtime so the - // model can pull characters / references / search before generating. - // Outside a project, fall through to the legacy one-shot path so - // `openmelon -p ...` still works in scratch directories. - cwd, _ := os.Getwd() - if wd, err := projectx.Discover(cwd); err == nil && wd != "" { - if err := runAgentInProject(ctx, opts, wd); err != nil { - return err - } - return nil - } - - // Legacy one-shot path: empty --llm = auto-detect (matches the new - // flag default, which is "" rather than "auto" to keep the new - // workflow-mode --llm wiring distinguishable). - agentLLMProvider := opts.llmProvider - if agentLLMProvider == "" { - agentLLMProvider = "auto" - } - llmClient, err := llm.New(agentLLMProvider, "", opts.llmBaseURL, opts.llmModel) - if err != nil { - switch { - case errors.Is(err, llm.ErrNoAPIKey): - return fmt.Errorf("no API key for %s — set %s in your environment", - opts.llmProvider, envVarFor(opts.llmProvider)) - case errors.Is(err, llm.ErrModelRequired): - return fmt.Errorf("--llm-model is required (e.g. --llm-model x-ai/grok-4 for openrouter, gpt-5 for openai, claude-sonnet-4-6 for anthropic) — we don't bake in vendor model defaults") - } - return fmt.Errorf("init LLM client: %w", err) - } - - var imgGen imagegen.Generator - if opts.imageEnabled { - imgGen, err = imagegen.New(opts.imageProvider, "", opts.imageBaseURL, opts.imageModel) - if err != nil { - switch { - case errors.Is(err, imagegen.ErrNoAPIKey): - envHint := "OPENAI_API_KEY" - if opts.imageProvider == "openrouter" { - envHint = "OPENROUTER_API_KEY" - } - return fmt.Errorf("image generation requires %s (or pass --image=false to skip)", envHint) - case errors.Is(err, imagegen.ErrModelRequired): - switch opts.imageProvider { - case "openrouter": - return fmt.Errorf("--image-model is required (e.g. openai/gpt-5-image, google/gemini-2.5-flash-image — see openrouter.ai/models?modality=image-output)") - default: - return fmt.Errorf("--image-model is required (e.g. --image-model gpt-image-1 or dall-e-3); pass --image=false to skip image generation entirely") - } - } - return fmt.Errorf("init image generator: %w", err) - } - } - - a := &agent.Agent{ - LLM: llmClient, - ImageGen: imgGen, - Compiler: &skillplus.Compiler{CompilerPath: opts.compilerPath}, - // Stream LLM tokens to stderr in agent mode so the user sees the - // model thinking instead of staring at a blank terminal. - StreamTo: os.Stderr, - } - - stamp := time.Now().UTC().Format("2006-01-02 15:04:05Z") - fmt.Fprintf(os.Stderr, "[openmelon %s] skill=%s llm=%s/%s", - stamp, opts.skillSpec, llmClient.Provider(), llmClient.Model()) - if imgGen != nil { - fmt.Fprintf(os.Stderr, " image=%s/%s", imgGen.Provider(), imgGen.Model()) - } - fmt.Fprintln(os.Stderr) - fmt.Fprintf(os.Stderr, "[openmelon] intent: %s\n", opts.intent) - - res, err := a.RunOneShot(ctx, agent.RunInput{ - Intent: opts.intent, - SkillSpec: opts.skillSpec, - Locale: opts.locale, - ModelProfile: opts.modelProfile, - OutputDir: opts.artifactDir, - ImageSize: opts.imageSize, - PackageSearchRoot: opts.skillRoot, - }) - if err != nil { - return err - } - - fmt.Fprintf(os.Stderr, "[openmelon] skill compiled: %s@%s\n", res.SkillID, res.SkillVersion) - if res.GenerationPrompt != "" { - fmt.Fprintf(os.Stderr, "[openmelon] generation prompt: %s\n", truncate(res.GenerationPrompt, 240)) - } - if res.ImagePath != "" { - fmt.Fprintf(os.Stderr, "[openmelon] image: %s (sha256=%s)\n", res.ImagePath, res.ImageSHA256[:12]) - } else if res.ArtifactPath != "" { - fmt.Fprintf(os.Stderr, "[openmelon] artifact: %s\n", res.ArtifactPath) - } - fmt.Fprintf(os.Stderr, "[openmelon] provenance: %s\n", res.ProvenancePath) - fmt.Fprintf(os.Stderr, "[openmelon] duration: %v\n", res.FinishedAt.Sub(res.StartedAt)) - - // Optional publish step. - if opts.publish == "vbox" { - if err := publishToVBox(ctx, res, opts); err != nil { - fmt.Fprintf(os.Stderr, "[openmelon] publish failed (artifact still saved locally): %v\n", err) - // Non-fatal: the local artifact is the primary deliverable. - } - } - - if opts.jsonOut { - summary, _ := json.MarshalIndent(res, "", " ") - fmt.Println(string(summary)) - } - - return nil -} - -func envVarFor(provider string) string { - switch provider { - case "anthropic": - return "ANTHROPIC_API_KEY" - case "openai": - return "OPENAI_API_KEY" - case "openrouter": - return "OPENROUTER_API_KEY" - } - return strings.ToUpper(provider) + "_API_KEY" -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -// publishToVBox shells to vbox-cli to upload the image and post. -// -// Failure mode: if vbox-cli is not installed, or VBOX_API_KEY is not set, -// or the post is rejected, the error is reported but the local artifact -// remains. The agent does not retry — it reports and stops. -func publishToVBox(ctx context.Context, res *agent.RunResult, opts agentOpts) error { - // Wired in cmd/openmelon/publish.go. Stubbed inline for now to keep - // the import surface tight; the real implementation lives next to - // the rest of agent-mode wiring. - return runPublishToVBox(ctx, res, opts) -} - -// ===================================================================== -// Workflow mode (0.1, legacy) -// ===================================================================== - -type workflowOpts struct { - projectPath string - workflowID string - intent string - artifactDir string - compilerPath string - doGenerate bool - generateCmd string - llmProvider string - llmModel string - provenancePath string -} - -func runWorkflow(ctx context.Context, opts workflowOpts) error { - fmt.Printf("[openmelon] loading project: %s\n", opts.projectPath) - proj, err := project.Load(opts.projectPath) - if err != nil { - return fmt.Errorf("load project: %w", err) - } - fmt.Printf("[openmelon] project: %s (%s)\n", proj.Name, proj.Platform) - - workflows, err := workflow.LoadWorkflows(opts.projectPath) - if err != nil { - return fmt.Errorf("load workflows: %w", err) - } - - var wfDef *workflow.WorkflowDefinition - if opts.workflowID != "" { - var ok bool - wfDef, ok = workflows[opts.workflowID] - if !ok { - return fmt.Errorf("workflow %q not found in project", opts.workflowID) - } - } else { - for _, wf := range workflows { - wfDef = wf - break - } - } - fmt.Printf("[openmelon] workflow: %s (%d stages)\n", wfDef.ID, len(wfDef.Stages)) - - compiler := &skillplus.Compiler{CompilerPath: opts.compilerPath} - - if opts.doGenerate && opts.llmProvider != "" && opts.generateCmd != "" { - return fmt.Errorf("--llm and --generate-cmd are mutually exclusive") - } - if opts.doGenerate && opts.llmProvider != "" && opts.llmModel == "" { - return fmt.Errorf("--llm-model is required when --llm is set") - } - - var provider generation.Provider - switch { - case opts.doGenerate && opts.llmProvider != "": - resolved := userconfig.ResolveProvider("", opts.llmProvider) - client, err := llm.New(opts.llmProvider, resolved.APIKey, resolved.BaseURL, opts.llmModel) - if err != nil { - return fmt.Errorf("llm init: %w", err) - } - provider = generation.NewLLMProvider(client) - case opts.doGenerate && opts.generateCmd != "": - provider = &generation.ShellProvider{Command: opts.generateCmd} - } - - provPath := opts.provenancePath - if provPath == "" { - provPath = filepath.Join(opts.artifactDir, "provenance.jsonl") - } - - engine := &workflow.Engine{} - req := &workflow.RunRequest{ - Project: proj, - WorkflowDef: wfDef, - Intent: opts.intent, - ArtifactDir: opts.artifactDir, - CompilerPath: opts.compilerPath, - ProjectDir: filepath.Dir(opts.projectPath), - ProvenancePath: provPath, - Compiler: compiler, - Provider: provider, - Generate: opts.doGenerate, - } - - fmt.Printf("[openmelon] running workflow stages...\n") - results, err := engine.Run(ctx, req) - if err != nil { - return fmt.Errorf("engine run: %w", err) - } - for _, r := range results { - fmt.Printf("[openmelon] stage %q → artifact %s written to %s\n", - r.Stage, r.Artifact.ID, opts.artifactDir) - } - fmt.Printf("[openmelon] done. %d stage(s) completed.\n", len(results)) - return nil -} diff --git a/cmd/openmelon/publish.go b/cmd/openmelon/publish.go deleted file mode 100644 index 3532d84..0000000 --- a/cmd/openmelon/publish.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -// publish.go — agent-mode helper that shells out to vbox-cli to publish -// the generated artifact. Kept in its own file so future publish targets -// (e.g. local file copy, S3, other social platforms) can slot in next to -// runPublishToVBox without growing main.go further. - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/agent" -) - -// runPublishToVBox uploads the generated image via `vbox-cli upload`, -// extracts the resulting fid, then posts via `vbox-cli post --media-fid`. -// -// Preconditions: -// - `vbox-cli` is on PATH -// - VBOX_API_KEY is set in the environment (vbox-cli reads it itself) -// - res.ImagePath is non-empty (otherwise nothing to upload) -// -// Failure mode: returns an error if any step fails. The caller treats -// this as non-fatal — the local artifact is the primary deliverable. -func runPublishToVBox(ctx context.Context, res *agent.RunResult, opts agentOpts) error { - if res.ImagePath == "" { - return fmt.Errorf("no image to publish — generation_prompt was empty or image generation was disabled") - } - if _, err := exec.LookPath("vbox-cli"); err != nil { - return fmt.Errorf("vbox-cli not on PATH — install with `npm i -g @e8s/vbox-cli`") - } - if os.Getenv("VBOX_API_KEY") == "" { - return fmt.Errorf("VBOX_API_KEY not set in env") - } - - // 1. Upload. - uploadOut, err := runVBoxCLI(ctx, "upload", "--file", res.ImagePath, "--category", "image") - if err != nil { - return fmt.Errorf("vbox-cli upload: %w", err) - } - fid, err := extractFID(uploadOut) - if err != nil { - return fmt.Errorf("parse upload result: %w (raw: %s)", err, uploadOut) - } - fmt.Fprintf(os.Stderr, "[openmelon] uploaded → fid=%s\n", fid) - - // 2. Post. - text := opts.postText - if text == "" { - text = opts.intent - } - postOut, err := runVBoxCLI(ctx, "post", "--text", text, "--media-fid", fid) - if err != nil { - return fmt.Errorf("vbox-cli post: %w", err) - } - fmt.Fprintf(os.Stderr, "[openmelon] published. vbox-cli response: %s\n", strings.TrimSpace(postOut)) - return nil -} - -// runVBoxCLI executes vbox-cli with the given args, returning stdout. -// Stderr is mirrored so the user sees vbox-cli's human messages. -func runVBoxCLI(ctx context.Context, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "vbox-cli", args...) - var stdout bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return "", err - } - return stdout.String(), nil -} - -// extractFID parses vbox-cli upload's stdout JSON to find the file id. -// -// vbox-cli upload returns the MediaItem shape: -// -// { "fid": "...", "ext": "png", "media_type": "image", ... } -func extractFID(uploadStdout string) (string, error) { - var m map[string]any - if err := json.Unmarshal([]byte(uploadStdout), &m); err != nil { - return "", err - } - fid, ok := m["fid"].(string) - if !ok || fid == "" { - return "", fmt.Errorf("no fid in response") - } - return fid, nil -} diff --git a/docs/rust-tui-rewrite.md b/docs/rust-tui-rewrite.md deleted file mode 100644 index e932308..0000000 --- a/docs/rust-tui-rewrite.md +++ /dev/null @@ -1,107 +0,0 @@ -# Rust TUI Rewrite - -This branch carries the Rust rewrite without replacing the current Go CLI yet. -The goal is no longer a throwaway prototype: the Rust binary must preserve the -same project/session/tool protocol while moving the interactive surface to a -normal terminal-scrollback architecture. - -## Direction - -OpenMelon should behave like a creator-native agent, not a full-screen dashboard. -The terminal should keep native scrollback, selection, copying, and resize -behavior. Model output, tool output, permission prompts, and user input should -be rendered as stream-friendly transcript blocks instead of a synthetic screen -buffer. - -The Rust TUI should therefore optimize for: - -- normal terminal scrollback and native copy behavior; -- a real terminal cursor and IME-friendly text editing; -- stable history navigation and interrupt semantics; -- Markdown-aware transcript rendering; -- clear tool, permission, error, and assistant block boundaries; -- terminal-native soft wrapping and resize reflow instead of hard-wrapped - synthetic screen lines; -- visible creator outputs under `outputs/`, with `.openmelon/` reserved for - internal state. - -## Current Slice - -The new workspace lives under `rust/` and currently contains one crate: - -- `crates/openmelon-tui`: a standalone prototype binary. - -The Rust binary now provides: - -- project layout discovery; -- hidden internal state at `.openmelon/`; -- visible creator output root at `outputs/`; -- readline-backed input history; -- normal-scrollback transcript output; -- Markdown block rendering for history/resume and line-buffered Markdown - rendering for streamed assistant text; -- status/help/history/session/events/copy/settings/model/model-image/skill/ - space/compact slash commands for local iteration; -- OpenAI/OpenRouter-compatible streaming chat completions with tool calls; -- `reasoning_effort` forwarding for GPT-5-family models; -- OpenAI-compatible request headers, including `openmelon-tui/` user - agent and OpenRouter `HTTP-Referer`/`X-Title`; -- project/global config and credentials resolution compatible with the Go CLI; -- session creation/resume using the same `.openmelon/sessions//` - `meta.json`, `messages.jsonl`, and `summary.json` layout; -- session lifecycle events for turn starts, model responses, tool calls, and - tool results; -- core tools for project inspection, file reads, search, visible artifact - saves, Skill-Plus compilation, guarded shell execution, image generation, - and `finish`. -- creator continuity tools for planning, space creation/activation, - decisions, feedback, provisional memory, episodes, reusable assets, and - compaction records. - -This still runs as a separate Rust binary and does not replace the existing Go -entrypoint. Keeping the entrypoints separate lets us test the Rust runtime -without breaking the current executable. - -## Usage - -```sh -cargo run --manifest-path rust/Cargo.toml -- repl -cargo run --manifest-path rust/Cargo.toml -- run "create today's episode brief" -cargo run --manifest-path rust/Cargo.toml -- resume -``` - -The Rust runtime reads the same project defaults: - -- `/.openmelon/project.json` -- `/.openmelon/credentials.json` -- `~/.openmelon/config.json` -- `~/.openmelon/credentials.json` -- provider environment variables - -## Migration Plan - -1. Keep the Go entrypoint as production while Rust runs as an explicit binary. -2. Verify Rust parity against real creator projects: resume, long output, - image generation, Skill-Plus, shell approval, and continuity reuse. -3. Harden the readline surface: multiline editing, Ctrl-C semantics, slash - completion, and history behavior must match the expected Codex/Claude-like - feel. -4. Port any remaining Go-only onboarding/setup commands or make the Rust - binary call into the same project config files without drift. -5. Replace the Go TUI entrypoint only after copy, scroll, resize, Markdown, - permission, model switching, output placement, and creator workflow parity - are verified in daily use. - -## Remaining Gaps - -- Anthropic native tool calling is not ported. This mirrors the current Go - interactive agent constraint: use OpenAI/OpenRouter-compatible providers for - tool-calling sessions. -- Bash approval is interactive and supports yes/always/no, but the Rust branch - does not yet have the Go TUI modal or LLM safety judge. -- The Rust input layer uses `rustyline`, not the full Go Bubble Tea visual - prompt. It gives native editing/history/IME behavior, but slash completion - and pending-input behavior still need the final Codex/Claude-like polish. -- Onboarding/setup/install commands remain in the Go CLI. -- The Go `openmelon` executable is still the production entrypoint until this - branch is promoted. diff --git a/docs/ts-tui-architecture.md b/docs/ts-tui-architecture.md index 5794313..774eb01 100644 --- a/docs/ts-tui-architecture.md +++ b/docs/ts-tui-architecture.md @@ -1,16 +1,14 @@ # TS-First TUI Architecture -OpenMelon's product TUI is moving to TypeScript and Ink. The goal is not to -replace the creator runtime in this step; it is to make the terminal experience -stable enough to become the main product surface. +OpenMelon's product TUI and default creator runtime are now TypeScript and Ink. +The goal is one product entrypoint that does not require non-TS runtimes. ## Decision -The TUI should be implemented as a TS/Ink application and installed as the -user-facing `openmelon` command when this migration is complete. - -Go remains the current runtime reference. Rust TUI work is kept as a prototype -and comparison point, but it is no longer the primary UI direction. +The user-facing `openmelon` command is the TS package under `tui/`. Historical +non-TS implementations must not be on the installed command path, and the +installed command, TUI, runtime, and common project commands must not spawn +non-TS binaries. ## Why @@ -40,7 +38,11 @@ The production TUI package lives in `tui/`. - `src/App.tsx` owns the UI state machine and key handling - `src/components/` contains visual components - `src/state/` contains reducer/state types -- `src/runtime/processBridge.ts` talks to the Go runtime bridge over JSONL +- `src/runtime/nativeClient.ts` runs the TS-native agent loop +- `src/runtime/openaiCompat.ts` implements OpenAI-compatible and Anthropic + streaming tool calls +- `src/commands/` contains TS-native project, registry, search, session, and + continuity-space CLI commands - `bin/openmelon.js` launches built JS when available, or TS source through `tsx` in development @@ -60,18 +62,15 @@ make tui-install ## Runtime Boundary -The product boundary is a JSONL process bridge: +The product boundary is an in-process TS runtime client: - TS owns the terminal UI, onboarding screens, slash palette, input behavior, - and normal scrollback rendering. -- Go owns the creator runtime, tool registry, session persistence, approvals, - model calls, image generation, and continuity tools. -- The bridge process is launched as `openmelon runtime-bridge [resume-id]`. -- Resume ids must be passed into the bridge, not only rendered by the UI, so - the next model call receives the loaded message history. -- Runtime configuration changes use `reload` instead of killing the bridge. - This preserves in-memory conversation history, pending input, the current - session directory, and per-session bash allowlists. + normal scrollback rendering, and creator runtime. +- The native runtime owns tool registry, session persistence, approvals, model + calls, image generation, and continuity tools. +- Runtime configuration changes use `reload` so the active runtime sees the new + settings without losing the session. +- `runtime-bridge` and process fallback are retired in the TS entrypoint. The UI expects runtime events shaped like: @@ -88,9 +87,9 @@ Input flows the other direction: - if the run has already ended, pending input starts a new run immediately - `/clear`, `/history`, and `/save` are runtime commands, not transcript-only UI commands. They operate on the LLM message history. -- `/model`, `/model-image`, and `/settings` persist to project config and then - call bridge `reload` so the active runtime sees the new settings without - losing the session. +- `/model`, `/model-image`, and `/settings` persist to project config and call + runtime `reload` so the active runtime sees the new settings without losing + the session. -This keeps the TUI independent from whether the runtime remains Go, moves to TS, -or runs as a separate native process. +This keeps the product path TS-native while allowing old non-TS code to be +inspected separately during migration. diff --git a/experiments/tui-spikes/README.md b/experiments/tui-spikes/README.md index b432003..6c949ee 100644 --- a/experiments/tui-spikes/README.md +++ b/experiments/tui-spikes/README.md @@ -1,7 +1,7 @@ # OpenMelon TUI Spikes -These are isolated prototypes for comparing terminal UI approaches before -choosing the production implementation. +These are isolated prototypes for validating terminal UI behavior before +folding ideas into the production implementation. They deliberately do not integrate the OpenMelon runtime. Each spike simulates: @@ -14,23 +14,6 @@ They deliberately do not integrate the OpenMelon runtime. Each spike simulates: - background model/tool output while the user keeps typing - pending input applied after the active run finishes -## Rust / reedline - -```sh -cargo run --manifest-path experiments/tui-spikes/rust/Cargo.toml -``` - -This version tests the line-editor-first design. Transcript lines are printed -with `ExternalPrinter`, while `reedline` owns the prompt, history, completion -menu, wrapping, and cursor/IME behavior. - -Try: - -- type `/` then press Tab to open the command menu -- type long Chinese text and resize the terminal -- press Ctrl-J or Shift-Enter for multiline input -- submit while simulated output is still streaming - ## TS / Ink ```sh @@ -39,17 +22,15 @@ npm install npm run dev ``` -This version tests a React component model. Transcript items are rendered via +This version tests the React component model. Transcript items are rendered via Ink static output, while the prompt, slash palette, history, and pending input are implemented in React state. -Try the same checks as the Rust version, especially Chinese IME candidate -placement and terminal resize behavior. +Try the checks below, especially Chinese IME candidate placement and terminal +resize behavior. ## Comparison checklist -Run both versions in the same terminal profile and compare these points: - 1. Type a long Chinese sentence and confirm the IME candidate window follows the cursor. 2. Paste a long mixed Chinese/English paragraph and resize the terminal while it @@ -65,6 +46,5 @@ Run both versions in the same terminal profile and compare these points: 8. Press Esc and Ctrl-C with non-empty input and verify the input clears without exiting. -The winning direction is the one that passes terminal behavior first. Visual -polish matters only after IME, resize, scrollback, copy, and concurrent output -are stable. +The production direction is TS/Ink. Visual polish matters only after IME, +resize, scrollback, copy, and concurrent output are stable. diff --git a/experiments/tui-spikes/rust/Cargo.lock b/experiments/tui-spikes/rust/Cargo.lock deleted file mode 100644 index 27476b2..0000000 --- a/experiments/tui-spikes/rust/Cargo.lock +++ /dev/null @@ -1,894 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "cc" -version = "1.2.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "serde", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openmelon-tui-spike-rust" -version = "0.1.0" -dependencies = [ - "anyhow", - "crossterm", - "nu-ansi-term", - "reedline", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "reedline" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2066729dce9fecd28d1c6850a159ee68719130f149b22467c362353e16994e90" -dependencies = [ - "chrono", - "crossbeam", - "crossterm", - "fd-lock", - "itertools", - "nu-ansi-term", - "serde", - "strip-ansi-escapes", - "strum", - "thiserror", - "unicase", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "strip-ansi-escapes" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" -dependencies = [ - "vte", -] - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "vte" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" -dependencies = [ - "memchr", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/experiments/tui-spikes/rust/Cargo.toml b/experiments/tui-spikes/rust/Cargo.toml deleted file mode 100644 index 3445192..0000000 --- a/experiments/tui-spikes/rust/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "openmelon-tui-spike-rust" -version = "0.1.0" -edition = "2021" -publish = false - -[dependencies] -anyhow = "1" -crossterm = "0.29" -nu-ansi-term = "0.50" -reedline = { version = "0.47", features = ["external_printer"] } diff --git a/experiments/tui-spikes/rust/src/main.rs b/experiments/tui-spikes/rust/src/main.rs deleted file mode 100644 index c40d8a3..0000000 --- a/experiments/tui-spikes/rust/src/main.rs +++ /dev/null @@ -1,281 +0,0 @@ -use std::{ - io::{self, Write}, - path::PathBuf, - thread, - time::{Duration, Instant}, -}; - -use anyhow::Result; -use crossterm::event::{KeyCode, KeyModifiers}; -use nu_ansi_term::{Color, Style}; -use reedline::{ - default_emacs_keybindings, Completer, DefaultPrompt, DefaultPromptSegment, DescriptionMenu, - EditCommand, Emacs, ExternalPrinter, FileBackedHistory, MenuBuilder, Reedline, ReedlineEvent, - ReedlineMenu, Signal, Span, Suggestion, -}; - -const COMMANDS: &[SlashCommand] = &[ - SlashCommand::new("/help", "show commands and keybindings"), - SlashCommand::new("/status", "show model, reasoning, project, and tokens"), - SlashCommand::new("/history", "print the simulated transcript"), - SlashCommand::new("/clear", "clear the current spike transcript"), - SlashCommand::new("/model", "switch the text model"), - SlashCommand::new("/model-image", "switch the image model"), - SlashCommand::new("/settings", "open settings"), - SlashCommand::new("/copy", "copy transcript via OSC52 in production"), - SlashCommand::new("/exit", "exit the spike"), -]; - -#[derive(Clone, Copy)] -struct SlashCommand { - name: &'static str, - description: &'static str, -} - -impl SlashCommand { - const fn new(name: &'static str, description: &'static str) -> Self { - Self { name, description } - } -} - -#[derive(Default)] -struct SlashCompleter; - -impl Completer for SlashCompleter { - fn complete(&mut self, line: &str, pos: usize) -> Vec { - if pos != line.len() { - return Vec::new(); - } - - let Some(prefix) = slash_prefix(line) else { - return Vec::new(); - }; - - COMMANDS - .iter() - .filter(|command| command.name.starts_with(prefix)) - .map(|command| Suggestion { - value: command.name.to_string(), - display_override: Some(command.name.to_string()), - description: Some(command.description.to_string()), - span: Span::new(0, prefix.len()), - append_whitespace: true, - ..Suggestion::default() - }) - .collect() - } -} - -fn slash_prefix(line: &str) -> Option<&str> { - let first_line = line.split('\n').next().unwrap_or_default(); - let trimmed = first_line.trim_start(); - if !trimmed.starts_with('/') || trimmed.contains(char::is_whitespace) { - return None; - } - Some(trimmed) -} - -fn main() -> Result<()> { - print_banner()?; - - let printer = ExternalPrinter::::new(512); - let output = printer.clone(); - let mut line_editor = build_line_editor(printer)?; - let prompt = DefaultPrompt::new( - DefaultPromptSegment::Basic("OpenMelon".into()), - DefaultPromptSegment::Basic("gpt-5.5 · xhigh · bigone".into()), - ); - - let mut quit_armed_until: Option = None; - let mut pending: Vec = Vec::new(); - - loop { - match line_editor.read_line(&prompt)? { - Signal::Success(input) => { - quit_armed_until = None; - let text = input.trim().to_string(); - if text.is_empty() { - continue; - } - if matches!(text.as_str(), "/exit" | "/quit" | "/q") { - break; - } - if text == "/help" { - print_help()?; - continue; - } - - run_simulated_turn(&output, text, &mut pending)?; - } - Signal::CtrlC => { - let now = Instant::now(); - if quit_armed_until.is_some_and(|until| now <= until) { - break; - } - quit_armed_until = Some(now + Duration::from_secs(2)); - println!( - "{}", - Color::Yellow.paint("input cleared; press Ctrl-C again within 2s to exit") - ); - } - Signal::CtrlD => break, - Signal::ExternalBreak(input) => { - if !input.trim().is_empty() { - pending.push(input); - println!( - "{}", - Color::Cyan.paint("external break queued current input") - ); - } - } - _ => {} - } - } - - println!("{}", Color::Fixed(8).paint("session saved at ")); - Ok(()) -} - -fn build_line_editor(printer: ExternalPrinter) -> Result { - let mut keybindings = default_emacs_keybindings(); - keybindings.add_binding( - KeyModifiers::NONE, - KeyCode::Tab, - ReedlineEvent::UntilFound(vec![ - ReedlineEvent::Menu("slash_menu".to_string()), - ReedlineEvent::MenuNext, - ]), - ); - keybindings.add_binding( - KeyModifiers::CONTROL, - KeyCode::Char('j'), - ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), - ); - keybindings.add_binding( - KeyModifiers::SHIFT, - KeyCode::Enter, - ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), - ); - keybindings.add_binding( - KeyModifiers::NONE, - KeyCode::Esc, - ReedlineEvent::Edit(vec![EditCommand::Clear]), - ); - - let menu = DescriptionMenu::default() - .with_name("slash_menu") - .with_marker(" / ") - .with_columns(1) - .with_selection_rows(8) - .with_description_rows(2) - .with_text_style(Style::new().fg(Color::White)) - .with_selected_text_style(Style::new().fg(Color::Black).on(Color::Cyan)) - .with_description_text_style(Style::new().fg(Color::Fixed(8))); - - let history_path = PathBuf::from(".openmelon/tui-spike-rust-history.txt"); - let history = FileBackedHistory::with_file(200, history_path)?; - - Ok(Reedline::create() - .use_bracketed_paste(true) - .with_ansi_colors(true) - .with_history(Box::new(history)) - .with_completer(Box::new(SlashCompleter)) - .with_menu(ReedlineMenu::EngineCompleter(Box::new(menu))) - .with_edit_mode(Box::new(Emacs::new(keybindings))) - .with_external_printer(printer) - .with_poll_interval(Duration::from_millis(33))) -} - -fn print_banner() -> Result<()> { - let mut out = io::stdout(); - writeln!( - out, - "{}", - Color::Cyan - .bold() - .paint("OpenMelon TUI Spike: Rust/reedline") - )?; - writeln!( - out, - "{}", - Color::White.paint("project bigone · model gpt-5.5 · reasoning xhigh · normal scrollback") - )?; - writeln!( - out, - "{}", - Color::Fixed(8).paint( - "Type / then Tab for the command menu. Ctrl-J/Shift-Enter newline. Esc clears input." - ) - )?; - writeln!(out)?; - out.flush()?; - Ok(()) -} - -fn print_help() -> Result<()> { - println!("{}", Color::Cyan.bold().paint("Commands")); - for command in COMMANDS { - println!( - " {} {}", - Color::Cyan.paint(command.name), - Color::Fixed(8).paint(command.description) - ); - } - println!(); - Ok(()) -} - -fn run_simulated_turn( - output: &ExternalPrinter, - text: String, - pending: &mut Vec, -) -> Result<()> { - output.print(format!("{} {}", Color::Cyan.paint("›"), text))?; - - let sender = output.clone(); - let user_text = text.clone(); - let handle = thread::spawn(move || -> Result<()> { - let long_line = "这是一段很长的中文输出,用来验证 resize、自动换行、复制和滚动行为;it also includes a very long English segment_that_should_wrap_without_breaking_the_prompt_or_overflowing_the_terminal_width."; - let steps = [ - format!( - "{} planning simulated creator workflow", - Color::Green.paint("●") - ), - format!(" {}", long_line), - format!( - "{} bash echo validating external printer and prompt redraw", - Color::Green.paint("●") - ), - format!( - " {}", - Color::Fixed(8).paint("tool result: ok; output stayed in scrollback") - ), - format!( - "{} received: {}", - Color::White.bold().paint("assistant"), - user_text - ), - ]; - for step in steps { - thread::sleep(Duration::from_millis(650)); - sender.print(step)?; - } - sender.print(String::new())?; - Ok(()) - }); - - // Keep the spike deterministic: the user can still type while the thread is - // emitting output because reedline owns the active prompt and redraws it. - handle.join().expect("simulated turn thread panicked")?; - - if !pending.is_empty() { - let queued = std::mem::take(pending).join("\n\n"); - output.print(format!( - "{} applying pending input at next model-call boundary", - Color::Purple.paint("↳") - ))?; - run_simulated_turn(output, queued, pending)?; - } - - Ok(()) -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 76bba31..0000000 --- a/go.mod +++ /dev/null @@ -1,34 +0,0 @@ -module github.com/eight-acres-lab/openmelon - -go 1.25.0 - -require ( - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.3.8 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 9210003..0000000 --- a/go.sum +++ /dev/null @@ -1,58 +0,0 @@ -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/agent/agent.go b/internal/agent/agent.go deleted file mode 100644 index 22d8dc6..0000000 --- a/internal/agent/agent.go +++ /dev/null @@ -1,462 +0,0 @@ -// Package agent is OpenMelon's content-creation agent loop. -// -// Today this package implements one-shot mode only: a single intent goes -// in, a single set of artifacts comes out. Multi-turn / interactive REPL -// mode lands in 0.3 (see ROADMAP.md). The interfaces here are written so -// the REPL is an additional entry point on top of the same primitives, -// not a parallel rewrite. -// -// One-shot flow: -// -// 1. Resolve the skill spec to a package directory on disk. -// 2. Compile the package via the Skill-Plus reference compiler. Use the -// full raw JSON output — the LLM call needs the output_schema to -// produce a valid structured response. -// 3. Send the compiled prompt + the user intent to the LLM. The LLM is -// asked to return a single JSON object matching the package's -// output_schema. -// 4. Parse the structured output. Fail loudly if it doesn't parse — the -// whole point of Skill-Plus is reproducible structured outputs, so a -// malformed response is a real bug, not something to silently retry. -// 5. If the structured output contains a `generation_prompt` AND an -// image generator is configured, produce one image and write it to -// the output directory. -// 6. Append a provenance line covering everything: skill, intent, -// locale, model profile, LLM provider+model, image provider+model, -// image hash if produced, file path, and a UTC timestamp. -package agent - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/imagegen" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/version" -) - -// Agent runs one-shot content-creation requests. -type Agent struct { - // LLM does the prompt-structuring step. Required. - LLM llm.Client - - // ImageGen produces images from the structured generation_prompt. - // Optional — if nil, the agent skips image generation and just - // returns the structured output. - ImageGen imagegen.Generator - - // Compiler is the Skill-Plus compiler subprocess wrapper. - // Required. - Compiler *skillplus.Compiler - - // StreamTo, if non-nil, receives the LLM's text deltas as they - // arrive from the network. cmd/openmelon sets this to os.Stderr in - // agent mode so the user sees progress while the model is still - // generating. Tests and the MCP server leave it nil to use the - // non-streaming path (simpler buffering, easier to assert on). - StreamTo io.Writer -} - -// RunInput describes a one-shot run. -type RunInput struct { - // Intent is the user's free-text creation request. Required. - Intent string - - // SkillSpec selects which Skill-Plus package to compile. Format: - // "skillplus:" — searched under PackageSearchRoot et al. - // "path:/abs/or/rel" — direct path to a .skillplus directory - // "" — treated as a path (no scheme prefix) - // Required. - SkillSpec string - - // Locale to compile for. Default "zh-CN". - Locale string - - // ModelProfile is the package's per-vendor prompt overlay slug. - // Default "gpt-image-family". - ModelProfile string - - // Vars are runtime overrides passed to the compiler as `--var k=v`. - Vars map[string]string - - // OutputDir is where visible artifacts (image, provenance) are written. - // Default "outputs/artifacts". - OutputDir string - - // ImageSize is passed to the image generator (WxH). Empty → vendor default. - ImageSize string - - // PackageSearchRoot is the directory under which skill specs of the - // form "skillplus:" are resolved as - // "/examples/.skillplus". - PackageSearchRoot string -} - -// RunResult captures everything produced by one run. -type RunResult struct { - SkillID string `json:"skill_id"` - SkillVersion string `json:"skill_version"` - Intent string `json:"intent"` - Compiled json.RawMessage `json:"-"` // verbose; not in summary JSON - Structured json.RawMessage `json:"structured"` - GenerationPrompt string `json:"generation_prompt,omitempty"` - ArtifactPath string `json:"artifact_path,omitempty"` - ImagePath string `json:"image_path,omitempty"` - ImageSHA256 string `json:"image_sha256,omitempty"` - ProvenancePath string `json:"provenance_path"` - StartedAt time.Time `json:"started_at"` - FinishedAt time.Time `json:"finished_at"` -} - -// RunOneShot executes the end-to-end one-shot flow. -func (a *Agent) RunOneShot(ctx context.Context, in RunInput) (*RunResult, error) { - if a.LLM == nil { - return nil, fmt.Errorf("agent: LLM is required") - } - if a.Compiler == nil { - return nil, fmt.Errorf("agent: Compiler is required") - } - if in.Intent == "" { - return nil, fmt.Errorf("agent: Intent is required") - } - if in.SkillSpec == "" { - return nil, fmt.Errorf("agent: SkillSpec is required") - } - - if in.Locale == "" { - in.Locale = "zh-CN" - } - if in.ModelProfile == "" { - in.ModelProfile = "gpt-image-family" - } - if in.OutputDir == "" { - in.OutputDir = "outputs/artifacts" - } - - startedAt := time.Now().UTC() - - // 1. Resolve skill spec to a package directory. - pkgDir, err := a.resolveSkillSpec(in.SkillSpec, in.PackageSearchRoot) - if err != nil { - return nil, fmt.Errorf("resolve skill spec %q: %w", in.SkillSpec, err) - } - - // 2. Compile the package — full raw JSON. - compiled, err := a.Compiler.CompileRaw(ctx, &skillplus.CompileRequest{ - PackagePath: pkgDir, - Target: "openmelon", - ModelProfile: in.ModelProfile, - Locale: in.Locale, - Vars: in.Vars, - }) - if err != nil { - return nil, fmt.Errorf("compile skill: %w", err) - } - - var compiledMap map[string]any - if err := json.Unmarshal(compiled, &compiledMap); err != nil { - return nil, fmt.Errorf("parse compiled output: %w", err) - } - - skillID := stringField(compiledMap, "package", "id") - skillVersion := stringField(compiledMap, "package", "version") - compiledPrompt, _ := compiledMap["compiled_prompt"].(string) - if compiledPrompt == "" { - return nil, fmt.Errorf("compiled output is missing compiled_prompt — check the package targets the openmelon target") - } - - // 3. Build the LLM call. - systemPrompt, err := buildSystemPrompt(compiledPrompt, compiledMap) - if err != nil { - return nil, fmt.Errorf("build system prompt: %w", err) - } - - llmOpts := llm.CompleteOptions{ - System: systemPrompt, - User: in.Intent, - JSONOnly: true, - } - var rawResponse string - if a.StreamTo != nil { - rawResponse, err = a.LLM.Stream(ctx, llmOpts, func(delta string) { - _, _ = io.WriteString(a.StreamTo, delta) - }) - } else { - rawResponse, err = a.LLM.Complete(ctx, llmOpts) - } - if err != nil { - return nil, fmt.Errorf("llm: %w", err) - } - - // 4. Parse structured output (with fence-stripping fallback). - structuredJSON, err := parseStructuredJSON(rawResponse) - if err != nil { - return nil, fmt.Errorf("llm returned invalid JSON: %w (raw response: %s)", err, rawResponse) - } - - var structured map[string]any - _ = json.Unmarshal(structuredJSON, &structured) - - if err := os.MkdirAll(in.OutputDir, 0o755); err != nil { - return nil, fmt.Errorf("create output dir: %w", err) - } - - result := &RunResult{ - SkillID: skillID, - SkillVersion: skillVersion, - Intent: in.Intent, - Compiled: compiled, - Structured: structuredJSON, - StartedAt: startedAt, - } - - // 5. Optional image generation; always persist structured output as artifact. - ts := startedAt.Format("20060102-150405") - if genPrompt, ok := structured["generation_prompt"].(string); ok && genPrompt != "" { - result.GenerationPrompt = genPrompt - if a.ImageGen != nil { - img, err := a.ImageGen.Generate(ctx, imagegen.GenerateOptions{ - Prompt: genPrompt, - Size: in.ImageSize, - }) - if err != nil { - return result, fmt.Errorf("image generation: %w", err) - } - imgPath := filepath.Join(in.OutputDir, fmt.Sprintf("%s-%s.png", skillID, ts)) - if err := os.WriteFile(imgPath, img.Data, 0o644); err != nil { - return result, fmt.Errorf("write image: %w", err) - } - sum := sha256.Sum256(img.Data) - result.ImagePath = imgPath - result.ArtifactPath = imgPath - result.ImageSHA256 = hex.EncodeToString(sum[:]) - } - } else { - // Text-only skill: persist structured JSON as artifact. - artPath := filepath.Join(in.OutputDir, fmt.Sprintf("%s-%s.json", skillID, ts)) - if err := os.WriteFile(artPath, structuredJSON, 0o644); err != nil { - return result, fmt.Errorf("write artifact: %w", err) - } - result.ArtifactPath = artPath - } - - result.FinishedAt = time.Now().UTC() - - // 6. Append provenance line. - provPath, err := writeProvenance(in.OutputDir, in, a, result) - if err != nil { - return result, fmt.Errorf("write provenance: %w", err) - } - result.ProvenancePath = provPath - - return result, nil -} - -// resolveSkillSpec turns a spec string into an absolute directory path. -// -// The "skillplus:" prefix looks for /examples/.skillplus -// under several candidate roots, in priority order: -// -// 1. PackageSearchRoot from RunInput (explicit user override) -// 2. The parent of Compiler.CompilerPath, when CompilerPath is set -// (editable skillplus checkout: /src → /examples) -// 3. -// 4. /../skillplus (workspace convention: openmelon and skillplus -// are sibling directories under e8s/) -// 5. $SKILLPLUS_EXAMPLES_ROOT, if set -// -// "path:" and bare paths are resolved directly without searching. -func (a *Agent) resolveSkillSpec(spec, searchRoot string) (string, error) { - if strings.HasPrefix(spec, "path:") { - return filepath.Abs(strings.TrimPrefix(spec, "path:")) - } - if strings.HasPrefix(spec, "skillplus:") { - name := strings.TrimPrefix(spec, "skillplus:") - var roots []string - if searchRoot != "" { - roots = append(roots, searchRoot) - } - if a.Compiler != nil && a.Compiler.CompilerPath != "" { - roots = append(roots, filepath.Dir(a.Compiler.CompilerPath)) - } - if cwd, err := os.Getwd(); err == nil { - roots = append(roots, cwd) - roots = append(roots, filepath.Join(cwd, "..", "skillplus")) - } - if env := os.Getenv("SKILLPLUS_EXAMPLES_ROOT"); env != "" { - roots = append(roots, env) - } - - var tried []string - for _, root := range roots { - for _, subdir := range []string{"examples", "skills"} { - candidate := filepath.Join(root, subdir, name+".skillplus") - tried = append(tried, candidate) - if info, err := os.Stat(candidate); err == nil && info.IsDir() { - abs, err := filepath.Abs(candidate) - if err == nil { - return abs, nil - } - return candidate, nil - } - } - } - return "", fmt.Errorf("skill %q not found.\nLooked in:\n %s\nPass --skill-root , or set $SKILLPLUS_EXAMPLES_ROOT", spec, strings.Join(tried, "\n ")) - } - // Bare path. - abs, err := filepath.Abs(spec) - if err != nil { - return "", err - } - if info, err := os.Stat(abs); err != nil || !info.IsDir() { - return "", fmt.Errorf("skill path %q does not exist or is not a directory", abs) - } - return abs, nil -} - -// buildSystemPrompt concatenates the package's compiled prompt with an -// explicit instruction to respond in JSON matching the package's output -// schema. The LLM sees: "\n\n# Output Schema\n\n -// # Response Format\n". -func buildSystemPrompt(compiledPrompt string, compiledMap map[string]any) (string, error) { - var b strings.Builder - b.WriteString(strings.TrimRight(compiledPrompt, "\n")) - b.WriteString("\n\n# Output Schema\n\n") - b.WriteString("Respond with a single JSON object matching this schema. ") - b.WriteString("Required fields must be present. Optional fields can be omitted if not applicable. ") - b.WriteString("Do NOT wrap the JSON in markdown fences. Do NOT add prose before or after the JSON.\n\n```json\n") - - schema, ok := compiledMap["output_schema"] - if !ok { - return "", fmt.Errorf("compiled output has no output_schema") - } - schemaBytes, err := json.MarshalIndent(schema, "", " ") - if err != nil { - return "", fmt.Errorf("marshal output_schema: %w", err) - } - b.Write(schemaBytes) - b.WriteString("\n```\n") - return b.String(), nil -} - -// parseStructuredJSON tries to parse the LLM response as JSON. -// Falls back to stripping markdown fences if the model added them. -func parseStructuredJSON(raw string) (json.RawMessage, error) { - trimmed := strings.TrimSpace(raw) - if json.Valid([]byte(trimmed)) { - return json.RawMessage(trimmed), nil - } - // Try fenced code block. - if strings.HasPrefix(trimmed, "```") { - lines := strings.Split(trimmed, "\n") - if len(lines) > 2 { - // Drop first line (```json or ```), drop last fence. - body := strings.Join(lines[1:len(lines)-1], "\n") - body = strings.TrimSpace(body) - if json.Valid([]byte(body)) { - return json.RawMessage(body), nil - } - } - } - // Try to extract the first {...} balanced span. - if start := strings.Index(trimmed, "{"); start >= 0 { - depth := 0 - for i := start; i < len(trimmed); i++ { - switch trimmed[i] { - case '{': - depth++ - case '}': - depth-- - if depth == 0 { - candidate := trimmed[start : i+1] - if json.Valid([]byte(candidate)) { - return json.RawMessage(candidate), nil - } - } - } - } - } - return nil, fmt.Errorf("could not extract JSON object from response") -} - -// stringField walks a path of nested map keys and returns the string -// at that path, or "" if anything along the way is missing or the -// wrong type. Convenience for the small handful of fields the agent -// loop reads from the compiled map. -func stringField(m map[string]any, keys ...string) string { - cur := any(m) - for _, k := range keys { - mm, ok := cur.(map[string]any) - if !ok { - return "" - } - cur = mm[k] - } - s, _ := cur.(string) - return s -} - -// writeProvenance appends a single JSONL line covering this run. -// -// Path: /provenance.jsonl. Created if missing. -// -// The provenance line is intentionally rich — anyone trying to reproduce -// this generation later needs the skill version, locale, model profile, -// LLM provider+model, image provider+model, and content hashes. Every -// field listed below is recorded. -func writeProvenance(outputDir string, in RunInput, a *Agent, r *RunResult) (string, error) { - provPath := filepath.Join(outputDir, "provenance.jsonl") - - entry := map[string]any{ - "ts": r.FinishedAt.Format(time.RFC3339), - "agent": "openmelon", - "agent_version": version.Version, - "skill": map[string]any{ - "id": r.SkillID, - "version": r.SkillVersion, - "locale": in.Locale, - "model_profile": in.ModelProfile, - "vars": in.Vars, - "spec": in.SkillSpec, - }, - "intent": in.Intent, - "llm": map[string]any{ - "provider": a.LLM.Provider(), - "model": a.LLM.Model(), - }, - "duration_ms": r.FinishedAt.Sub(r.StartedAt).Milliseconds(), - } - if a.ImageGen != nil && r.ImagePath != "" { - entry["image"] = map[string]any{ - "provider": a.ImageGen.Provider(), - "model": a.ImageGen.Model(), - "path": r.ImagePath, - "sha256": r.ImageSHA256, - "prompt_chars": len(r.GenerationPrompt), - } - } - - line, err := json.Marshal(entry) - if err != nil { - return "", err - } - - f, err := os.OpenFile(provPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return "", err - } - defer f.Close() - if _, err := f.Write(append(line, '\n')); err != nil { - return "", err - } - return provPath, nil -} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go deleted file mode 100644 index 27ec182..0000000 --- a/internal/agent/agent_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/skillplus" -) - -// fakeCompilerOutput mimics what `skillplus … --target openmelon` returns. -const fakeCompilerOutput = `{ - "target": "openmelon", - "package": {"id": "food-street-realism", "version": "0.1.0"}, - "compiled_prompt": "You are a visual prompt director. Convert intent into observable visual evidence.", - "runtime_vars": {"realism_level": "high"}, - "model_profile": "gpt-image-family", - "evaluation": {"checklist": ["a", "b"]}, - "output_schema": { - "type": "object", - "required": ["scene_interpretation", "generation_prompt"], - "properties": { - "scene_interpretation": {"type": "object"}, - "generation_prompt": {"type": "string"} - } - }, - "stage_contract": {"stage": "visual_prompt_concretization"} -}` - -// makeFakeCompiler writes a fake skillplus binary that always emits -// fakeCompilerOutput, and returns a Compiler configured to use it. -func makeFakeCompiler(t *testing.T) *skillplus.Compiler { - t.Helper() - tmp := t.TempDir() - bin := filepath.Join(tmp, "skillplus-fake") - script := "#!/bin/sh\ncat << 'ENDJSON'\n" + fakeCompilerOutput + "\nENDJSON\n" - if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - return &skillplus.Compiler{SkillplusBinary: bin} -} - -// fakeLLM serves a fixed structured response. -func fakeLLMServer(t *testing.T, response string) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // Emit a minimal Anthropic-shaped response. - _ = json.NewEncoder(w).Encode(map[string]any{ - "content": []map[string]any{{"type": "text", "text": response}}, - }) - })) -} - -func TestRunOneShot_HappyPath_NoImage(t *testing.T) { - llmSrv := fakeLLMServer(t, `{"scene_interpretation":{"camera":"phone"},"generation_prompt":"a noodle shop, ordinary lighting"}`) - defer llmSrv.Close() - - llmClient, err := buildAnthropic(llmSrv.URL) - if err != nil { - t.Fatal(err) - } - - // Make a fake .skillplus dir that the agent can resolve via "path:" spec. - pkgDir := t.TempDir() - a := &Agent{ - LLM: llmClient, - Compiler: makeFakeCompiler(t), - } - - out := t.TempDir() - res, err := a.RunOneShot(context.Background(), RunInput{ - Intent: "下班吃一碗牛肉面", - SkillSpec: "path:" + pkgDir, - OutputDir: out, - }) - if err != nil { - t.Fatalf("RunOneShot: %v", err) - } - if res.SkillID != "food-street-realism" { - t.Errorf("SkillID = %q", res.SkillID) - } - if res.GenerationPrompt == "" { - t.Errorf("expected generation_prompt to be extracted from structured output") - } - if res.ImagePath != "" { - t.Errorf("ImagePath should be empty when no ImageGen configured, got %q", res.ImagePath) - } - if res.ProvenancePath == "" { - t.Errorf("ProvenancePath should be set") - } - - // Verify the provenance file actually has a line. - data, err := os.ReadFile(res.ProvenancePath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "food-street-realism") { - t.Errorf("provenance does not include skill id: %s", data) - } - if !strings.Contains(string(data), "下班吃一碗牛肉面") { - t.Errorf("provenance does not include intent") - } -} - -func TestRunOneShot_HandlesFencedJSON(t *testing.T) { - fenced := "```json\n" + `{"scene_interpretation":{},"generation_prompt":"x"}` + "\n```" - llmSrv := fakeLLMServer(t, fenced) - defer llmSrv.Close() - - llmClient, err := buildAnthropic(llmSrv.URL) - if err != nil { - t.Fatal(err) - } - - a := &Agent{LLM: llmClient, Compiler: makeFakeCompiler(t)} - pkgDir := t.TempDir() - _, err = a.RunOneShot(context.Background(), RunInput{ - Intent: "x", SkillSpec: "path:" + pkgDir, OutputDir: t.TempDir(), - }) - if err != nil { - t.Fatalf("expected fence-stripping to succeed: %v", err) - } -} - -func TestRunOneShot_FailsOnInvalidLLMResponse(t *testing.T) { - llmSrv := fakeLLMServer(t, "this is not json at all just prose") - defer llmSrv.Close() - - llmClient, err := buildAnthropic(llmSrv.URL) - if err != nil { - t.Fatal(err) - } - - a := &Agent{LLM: llmClient, Compiler: makeFakeCompiler(t)} - pkgDir := t.TempDir() - _, err = a.RunOneShot(context.Background(), RunInput{ - Intent: "x", SkillSpec: "path:" + pkgDir, OutputDir: t.TempDir(), - }) - if err == nil || !strings.Contains(err.Error(), "invalid JSON") { - t.Errorf("expected invalid JSON error, got %v", err) - } -} - -func TestRunOneShot_RequiresFields(t *testing.T) { - a := &Agent{} - _, err := a.RunOneShot(context.Background(), RunInput{}) - if err == nil { - t.Fatal("expected error for empty input") - } -} - -func TestResolveSkillSpec_PathPrefix(t *testing.T) { - a := &Agent{} - dir := t.TempDir() - got, err := a.resolveSkillSpec("path:"+dir, "") - if err != nil { - t.Fatal(err) - } - want, _ := filepath.Abs(dir) - if got != want { - t.Errorf("got %q want %q", got, want) - } -} - -func TestResolveSkillSpec_SkillplusPrefix(t *testing.T) { - root := t.TempDir() - pkgDir := filepath.Join(root, "examples", "food-street-realism.skillplus") - if err := os.MkdirAll(pkgDir, 0o755); err != nil { - t.Fatal(err) - } - a := &Agent{} - got, err := a.resolveSkillSpec("skillplus:food-street-realism", root) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if got != pkgDir { - t.Errorf("got %q want %q", got, pkgDir) - } -} - -func TestResolveSkillSpec_NotFound(t *testing.T) { - a := &Agent{} - _, err := a.resolveSkillSpec("skillplus:nope", t.TempDir()) - if err == nil || !strings.Contains(err.Error(), "not found") { - t.Errorf("expected not-found error, got %v", err) - } -} - -func TestParseStructuredJSON_PlainJSON(t *testing.T) { - got, err := parseStructuredJSON(`{"a":1}`) - if err != nil { - t.Fatal(err) - } - if string(got) != `{"a":1}` { - t.Errorf("got %s", got) - } -} - -func TestParseStructuredJSON_StripsFences(t *testing.T) { - got, err := parseStructuredJSON("```json\n{\"a\":1}\n```") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(got), `"a":1`) { - t.Errorf("got %s", got) - } -} - -func TestParseStructuredJSON_ExtractsFromProse(t *testing.T) { - got, err := parseStructuredJSON(`Sure! Here's the JSON: {"a":1} let me know if you need anything else.`) - if err != nil { - t.Fatal(err) - } - if string(got) != `{"a":1}` { - t.Errorf("got %s", got) - } -} - -// Build an Anthropic client pointed at the fake test server. -func buildAnthropic(baseURL string) (llm.Client, error) { - c, err := llm.NewAnthropic("test-key", "", "claude-test") - if err != nil { - return nil, err - } - // Re-point at the test server via the (unexported) field. - // We do this with a tiny helper rather than a public Setter so the - // production constructor stays simple. - setBaseURLForTest(c, baseURL) - return c, nil -} - -// setBaseURLForTest is implemented in agent_testhelpers.go (same package). diff --git a/internal/agent/agent_testhelpers_test.go b/internal/agent/agent_testhelpers_test.go deleted file mode 100644 index 4b17769..0000000 --- a/internal/agent/agent_testhelpers_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package agent - -import ( - "reflect" - - "github.com/eight-acres-lab/openmelon/internal/llm" -) - -// setBaseURLForTest pokes the unexported `baseURL` field on an Anthropic -// or OpenAI client so tests can point it at httptest servers. -// -// Production code never calls this — it lives in a non-_test.go file -// only because Go's test packages cannot reach unexported fields in -// other packages. Keeping the reflection in one helper avoids -// scattering reflect calls through the test file. -func setBaseURLForTest(c llm.Client, url string) { - v := reflect.ValueOf(c).Elem() - f := v.FieldByName("baseURL") - if !f.IsValid() { - return - } - // Bypass unexported-field assignment guard via unsafe pointer trick. - // Acceptable because this file is build-only-for-tests by intent. - rf := reflect.NewAt(f.Type(), f.Addr().UnsafePointer()).Elem() - rf.SetString(url) -} diff --git a/internal/artifacts/artifact.go b/internal/artifacts/artifact.go deleted file mode 100644 index ff0e011..0000000 --- a/internal/artifacts/artifact.go +++ /dev/null @@ -1,22 +0,0 @@ -package artifacts - -// Type identifies a production artifact type. -type Type string - -const ( - TypeCopyDraft Type = "copy_draft" - TypeImagePrompt Type = "image_prompt" - TypeImage Type = "image" - TypeAudioScript Type = "audio_script" - TypeVideoShot Type = "video_shot" - TypeReviewReport Type = "review_report" -) - -// Artifact is a typed production output with labels and provenance. -type Artifact struct { - ID string - Type Type - Content string - Labels map[string]string - Provenance string -} diff --git a/internal/artifacts/writer.go b/internal/artifacts/writer.go deleted file mode 100644 index 261d01b..0000000 --- a/internal/artifacts/writer.go +++ /dev/null @@ -1,63 +0,0 @@ -package artifacts - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "strings" -) - -// StableID returns a deterministic 16-character hex ID derived from the SHA256 of the -// concatenation of parts joined by ":". -func StableID(parts ...string) string { - h := sha256.Sum256([]byte(strings.Join(parts, ":"))) - return hex.EncodeToString(h[:])[:16] -} - -// Write persists the artifact to dir, creating the directory if needed. -// It writes two files: -// - {id}.{type}.txt — the artifact content -// - {id}.provenance.json — the raw provenance snapshot stored in a.Provenance -// -// Collision handling: if a content file with the same ID already exists, Write -// appends a version suffix (-v2, -v3, …) and updates a.ID to the resolved value. -// This prevents silent overwrites as required by EC-003. -func Write(dir string, a *Artifact) error { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("artifacts.Write: mkdir %q: %w", dir, err) - } - - typeName := strings.ReplaceAll(string(a.Type), "_", ".") - - // Resolve a collision-free ID. - baseID := a.ID - resolvedID := baseID - for version := 2; ; version++ { - candidate := filepath.Join(dir, resolvedID+"."+typeName+".txt") - if _, err := os.Stat(candidate); os.IsNotExist(err) { - break - } - if version > 100 { - return fmt.Errorf("artifacts.Write: too many collisions for id %q (>100 versions)", baseID) - } - resolvedID = fmt.Sprintf("%s-v%d", baseID, version) - } - // Update the artifact so callers see the actual ID that was persisted. - a.ID = resolvedID - - contentPath := filepath.Join(dir, resolvedID+"."+typeName+".txt") - if err := os.WriteFile(contentPath, []byte(a.Content), 0o644); err != nil { - return fmt.Errorf("artifacts.Write: write content %q: %w", contentPath, err) - } - - if a.Provenance != "" { - provPath := filepath.Join(dir, resolvedID+".provenance.json") - if err := os.WriteFile(provPath, []byte(a.Provenance), 0o644); err != nil { - return fmt.Errorf("artifacts.Write: write provenance %q: %w", provPath, err) - } - } - - return nil -} diff --git a/internal/artifacts/writer_test.go b/internal/artifacts/writer_test.go deleted file mode 100644 index b2e0093..0000000 --- a/internal/artifacts/writer_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package artifacts - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestStableID_deterministic(t *testing.T) { - id1 := StableID("proj", "workflow", "stage") - id2 := StableID("proj", "workflow", "stage") - if id1 != id2 { - t.Errorf("StableID is not deterministic: %q != %q", id1, id2) - } -} - -func TestStableID_differentParts(t *testing.T) { - a := StableID("a", "b", "c") - b := StableID("a", "b", "d") - if a == b { - t.Error("StableID should differ for different parts") - } -} - -func TestStableID_length(t *testing.T) { - id := StableID("x") - if len(id) != 16 { - t.Errorf("StableID length = %d, want 16", len(id)) - } -} - -func TestWrite_createsFiles(t *testing.T) { - dir := t.TempDir() - a := &Artifact{ - ID: "abcd1234", - Type: TypeImagePrompt, - Content: "some prompt content", - Provenance: `{"artifact_id":"abcd1234"}`, - } - - if err := Write(dir, a); err != nil { - t.Fatalf("Write returned error: %v", err) - } - - contentPath := filepath.Join(dir, "abcd1234.image.prompt.txt") - data, err := os.ReadFile(contentPath) - if err != nil { - t.Fatalf("content file not found: %v", err) - } - if string(data) != "some prompt content" { - t.Errorf("content = %q, want %q", string(data), "some prompt content") - } - - provPath := filepath.Join(dir, "abcd1234.provenance.json") - pData, err := os.ReadFile(provPath) - if err != nil { - t.Fatalf("provenance file not found: %v", err) - } - if !strings.Contains(string(pData), "abcd1234") { - t.Errorf("provenance file missing artifact_id, got: %q", string(pData)) - } -} - -func TestWrite_noProvenanceField(t *testing.T) { - dir := t.TempDir() - a := &Artifact{ - ID: "ef567890", - Type: TypeCopyDraft, - Content: "copy draft content", - } - - if err := Write(dir, a); err != nil { - t.Fatalf("Write returned error: %v", err) - } - - provPath := filepath.Join(dir, "ef567890.provenance.json") - if _, err := os.Stat(provPath); !os.IsNotExist(err) { - t.Error("provenance file should NOT be written when Provenance is empty") - } -} - -// TestWrite_collision verifies EC-003: a second Write with the same ID -// must not silently overwrite the first file; it must use a -v2 suffix. -func TestWrite_collision(t *testing.T) { - dir := t.TempDir() - - a1 := &Artifact{ID: "col12345", Type: TypeImagePrompt, Content: "first content"} - if err := Write(dir, a1); err != nil { - t.Fatalf("first Write error: %v", err) - } - if a1.ID != "col12345" { - t.Errorf("first write: ID should be unchanged, got %q", a1.ID) - } - - // Second write with same original ID — should get -v2 suffix. - a2 := &Artifact{ID: "col12345", Type: TypeImagePrompt, Content: "second content"} - if err := Write(dir, a2); err != nil { - t.Fatalf("second Write error: %v", err) - } - if a2.ID != "col12345-v2" { - t.Errorf("second write: expected ID %q, got %q", "col12345-v2", a2.ID) - } - - // Original file content must be preserved (not overwritten). - data, err := os.ReadFile(filepath.Join(dir, "col12345.image.prompt.txt")) - if err != nil { - t.Fatalf("original file missing: %v", err) - } - if string(data) != "first content" { - t.Errorf("original file overwritten: got %q", string(data)) - } - - // -v2 file must contain the second content. - data2, err := os.ReadFile(filepath.Join(dir, "col12345-v2.image.prompt.txt")) - if err != nil { - t.Fatalf("v2 file missing: %v", err) - } - if string(data2) != "second content" { - t.Errorf("v2 file wrong content: got %q", string(data2)) - } -} diff --git a/internal/continuity/continuity.go b/internal/continuity/continuity.go deleted file mode 100644 index de1a347..0000000 --- a/internal/continuity/continuity.go +++ /dev/null @@ -1,1080 +0,0 @@ -// Package continuity stores OpenMelon's long-running creative spaces. -// -// The first implementation is deliberately file-backed and boring: -// .openmelon/spaces// holds the model-readable assumptions, canon, -// decision log, feedback log, plan, assets, and episodes. The goal is to -// give the agent durable context it can inspect and update, not to -// introduce a database before the workflow is proven. -package continuity - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -const ( - SpacesDirName = "spaces" - SpaceFileName = "space.json" - AssumptionsFileName = "assumptions.md" - CanonFileName = "canon.md" - MemoryFileName = "memory.md" - MemoryItemsFile = "memory.jsonl" - CompactionFile = "compactions.jsonl" - PlanFileName = "plan.md" - DecisionsFile = "decisions.jsonl" - FeedbackFile = "feedback.jsonl" - EpisodesDirName = "episodes" - AssetsDirName = "assets" - DefaultAssumptionsBody = "# Assumptions\n\nModel-generated setup assumptions live here until the user confirms, rejects, or edits them. These are lower authority than canon and decisions.\n" - DefaultCanonBody = "# Canon\n\nConfirmed long-term rules live here. Do not infer new canon without user confirmation.\n\n## Voice\n- TBD\n\n## Visual Style\n- TBD\n\n## Episode Structure\n- TBD\n" - DefaultMemoryBody = "# Memory\n\n" - DefaultPlanBody = "# Plan\n\n## Backlog\n- TBD\n" -) - -var slugRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - -var ( - ErrNotFound = errors.New("continuity: not found") - ErrAlreadyExists = errors.New("continuity: already exists") -) - -type Space struct { - ID string `json:"id"` - Name string `json:"name"` - Platform string `json:"platform,omitempty"` - Audience string `json:"audience,omitempty"` - Status string `json:"status,omitempty"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CreateSpaceOptions struct { - ID string - Name string - Platform string - Audience string - Status string - Description string - Tags []string - Assumptions string -} - -type Decision struct { - ID string `json:"id"` - Scope string `json:"scope,omitempty"` - Target string `json:"target,omitempty"` - Decision string `json:"decision"` - Reason string `json:"reason,omitempty"` - Weight float64 `json:"weight,omitempty"` - Status string `json:"status,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -type Feedback struct { - ID string `json:"id"` - EpisodeID string `json:"episode_id,omitempty"` - Source string `json:"source,omitempty"` - Signal string `json:"signal"` - Evidence string `json:"evidence,omitempty"` - Recommendation string `json:"recommendation,omitempty"` - WeightDelta map[string]float64 `json:"weight_delta,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -type Episode struct { - ID string `json:"id"` - Title string `json:"title,omitempty"` - Topic string `json:"topic,omitempty"` - Status string `json:"status,omitempty"` - Brief string `json:"brief,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Asset struct { - ID string `json:"id"` - Kind string `json:"kind,omitempty"` - SpaceID string `json:"space_id,omitempty"` - Status string `json:"status,omitempty"` - Description string `json:"description,omitempty"` - ReusePolicy string `json:"reuse_policy,omitempty"` - Files []string `json:"files,omitempty"` - Tags []string `json:"tags,omitempty"` - Weight float64 `json:"weight,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Hit struct { - Space *Space `json:"space"` - Score int `json:"score"` -} - -type ContextPacket struct { - ProjectID string `json:"project_id"` - Authority string `json:"authority"` - Space *Space `json:"space"` - Selection *Selection `json:"selection,omitempty"` - Assumptions string `json:"assumptions,omitempty"` - Canon string `json:"canon,omitempty"` - Memory string `json:"memory,omitempty"` - Plan string `json:"plan,omitempty"` - RecentDecisions []Decision `json:"recent_decisions,omitempty"` - RecentFeedback []Feedback `json:"recent_feedback,omitempty"` - RecentEpisodes []Episode `json:"recent_episodes,omitempty"` - Assets []Asset `json:"assets,omitempty"` -} - -type SelectionOptions struct { - Query string - MaxDecisions int - MaxFeedback int - MaxEpisodes int - MaxAssets int - IncludeDrafts bool -} - -type Selection struct { - Query string `json:"query,omitempty"` - DecisionLimit int `json:"decision_limit"` - FeedbackLimit int `json:"feedback_limit"` - EpisodeLimit int `json:"episode_limit"` - AssetLimit int `json:"asset_limit"` - Reasons []string `json:"reasons,omitempty"` - Truncated []string `json:"truncated,omitempty"` -} - -type MemoryItem struct { - ID string `json:"id"` - Kind string `json:"kind,omitempty"` - Scope string `json:"scope,omitempty"` - Target string `json:"target,omitempty"` - Content string `json:"content"` - Source string `json:"source,omitempty"` - Weight float64 `json:"weight,omitempty"` - Status string `json:"status,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type MemoryPromotion struct { - ItemID string `json:"item_id"` - Decision string `json:"decision"` - Reason string `json:"reason,omitempty"` - Target string `json:"target,omitempty"` -} - -type SpaceCompaction struct { - ID string `json:"id"` - Summary string `json:"summary"` - Scope string `json:"scope,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -type WorkflowPlan struct { - Intent string `json:"intent"` - Mode string `json:"mode"` - SpaceID string `json:"space_id,omitempty"` - NeedsConfirmation bool `json:"needs_confirmation"` - Reason string `json:"reason"` - Steps []WorkflowStep `json:"steps"` -} - -type WorkflowStep struct { - ID string `json:"id"` - Action string `json:"action"` - Tool string `json:"tool,omitempty"` - Reason string `json:"reason"` -} - -func SpacesDir(workdir string) string { - return filepath.Join(projectx.StateDir(workdir), SpacesDirName) -} - -func SpaceDir(workdir, id string) string { - return filepath.Join(SpacesDir(workdir), id) -} - -func ValidateID(id string) error { - if len(id) < 2 || len(id) > 64 { - return fmt.Errorf("continuity: id %q must be 2..64 chars", id) - } - if !slugRe.MatchString(id) { - return fmt.Errorf("continuity: id %q must be kebab-case ([a-z][a-z0-9-]*)", id) - } - if strings.HasSuffix(id, "-") || strings.Contains(id, "--") { - return fmt.Errorf("continuity: id %q must not have trailing or doubled hyphens", id) - } - return nil -} - -func CreateSpace(workdir string, opts CreateSpaceOptions) (*Space, error) { - if err := ValidateID(opts.ID); err != nil { - return nil, err - } - if strings.TrimSpace(opts.Name) == "" { - opts.Name = opts.ID - } - status := strings.TrimSpace(opts.Status) - if status == "" { - status = "draft" - } - dir := SpaceDir(workdir, opts.ID) - if _, err := os.Stat(filepath.Join(dir, SpaceFileName)); err == nil { - return nil, fmt.Errorf("%w: %s", ErrAlreadyExists, opts.ID) - } else if !os.IsNotExist(err) { - return nil, err - } - if err := os.MkdirAll(filepath.Join(dir, EpisodesDirName), 0o755); err != nil { - return nil, err - } - if err := os.MkdirAll(filepath.Join(dir, AssetsDirName), 0o755); err != nil { - return nil, err - } - now := time.Now().UTC() - sp := &Space{ - ID: opts.ID, - Name: opts.Name, - Platform: strings.TrimSpace(opts.Platform), - Audience: strings.TrimSpace(opts.Audience), - Status: status, - Description: strings.TrimSpace(opts.Description), - Tags: cleanTags(opts.Tags), - CreatedAt: now, - UpdatedAt: now, - } - if err := writeJSON(filepath.Join(dir, SpaceFileName), sp); err != nil { - return nil, err - } - assumptions := strings.TrimSpace(opts.Assumptions) - if assumptions == "" { - assumptions = DefaultAssumptionsBody - } else if !strings.HasSuffix(assumptions, "\n") { - assumptions += "\n" - } - for path, body := range map[string]string{ - filepath.Join(dir, AssumptionsFileName): assumptions, - filepath.Join(dir, CanonFileName): DefaultCanonBody, - filepath.Join(dir, MemoryFileName): DefaultMemoryBody, - filepath.Join(dir, PlanFileName): DefaultPlanBody, - } { - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - return nil, err - } - } - return sp, nil -} - -func ListSpaces(workdir string) ([]*Space, error) { - root := SpacesDir(workdir) - entries, err := os.ReadDir(root) - if err != nil { - if os.IsNotExist(err) { - return []*Space{}, nil - } - return nil, err - } - out := make([]*Space, 0, len(entries)) - for _, e := range entries { - if !e.IsDir() { - continue - } - sp, err := GetSpace(workdir, e.Name()) - if err == nil { - out = append(out, sp) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out, nil -} - -func GetSpace(workdir, id string) (*Space, error) { - if err := ValidateID(id); err != nil { - return nil, err - } - path := filepath.Join(SpaceDir(workdir, id), SpaceFileName) - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: space %s", ErrNotFound, id) - } - return nil, err - } - var sp Space - if err := json.Unmarshal(b, &sp); err != nil { - return nil, err - } - return &sp, nil -} - -func SearchSpaces(workdir, query string) ([]Hit, error) { - spaces, err := ListSpaces(workdir) - if err != nil { - return nil, err - } - terms := searchTerms(query) - var hits []Hit - for _, sp := range spaces { - score := 0 - hay := strings.ToLower(strings.Join([]string{ - sp.ID, sp.Name, sp.Description, sp.Platform, sp.Audience, strings.Join(sp.Tags, " "), - }, "\n")) - if strings.TrimSpace(query) == "" { - score = 1 - } - for _, term := range terms { - switch { - case sp.ID == term: - score += 10 - case strings.Contains(hay, term): - score += 2 - default: - score = -1 - } - if score < 0 { - break - } - } - if sp.Status == "active" && score >= 0 { - score += 3 - } - if score >= 0 { - hits = append(hits, Hit{Space: sp, Score: score}) - } - } - sort.SliceStable(hits, func(i, j int) bool { - if hits[i].Score != hits[j].Score { - return hits[i].Score > hits[j].Score - } - return hits[i].Space.ID < hits[j].Space.ID - }) - return hits, nil -} - -func searchTerms(query string) []string { - stop := map[string]bool{ - "continue": true, - "again": true, - "yesterday": true, - "today": true, - "tomorrow": true, - "next": true, - "series": true, - "episode": true, - "post": true, - "the": true, - "a": true, - "an": true, - } - var terms []string - for _, raw := range strings.Fields(strings.ToLower(query)) { - term := strings.Trim(raw, " \t\r\n.,;:!?()[]{}\"'") - if term == "" || stop[term] { - continue - } - terms = append(terms, term) - } - return terms -} - -func ReadCanon(workdir, id string) (string, error) { - return readText(filepath.Join(SpaceDir(workdir, id), CanonFileName)) -} - -func ReadAssumptions(workdir, id string) (string, error) { - return readText(filepath.Join(SpaceDir(workdir, id), AssumptionsFileName)) -} - -func WriteAssumptions(workdir, id, body string) error { - if _, err := GetSpace(workdir, id); err != nil { - return err - } - if !strings.HasSuffix(body, "\n") { - body += "\n" - } - return os.WriteFile(filepath.Join(SpaceDir(workdir, id), AssumptionsFileName), []byte(body), 0o644) -} - -func WriteCanon(workdir, id, body string) error { - if _, err := GetSpace(workdir, id); err != nil { - return err - } - if !strings.HasSuffix(body, "\n") { - body += "\n" - } - return os.WriteFile(filepath.Join(SpaceDir(workdir, id), CanonFileName), []byte(body), 0o644) -} - -func ActivateSpace(workdir, id string, d Decision) (*Space, *Decision, error) { - sp, err := GetSpace(workdir, id) - if err != nil { - return nil, nil, err - } - if strings.TrimSpace(d.Decision) == "" { - return nil, nil, fmt.Errorf("continuity: activation decision is required") - } - if d.Scope == "" { - d.Scope = "space" - } - if d.Target == "" { - d.Target = "space_activation" - } - dec, err := RecordDecision(workdir, id, d) - if err != nil { - return nil, nil, err - } - now := time.Now().UTC() - sp.Status = "active" - sp.UpdatedAt = now - if err := writeJSON(filepath.Join(SpaceDir(workdir, id), SpaceFileName), sp); err != nil { - return nil, nil, err - } - return sp, dec, nil -} - -func RecordDecision(workdir, spaceID string, d Decision) (*Decision, error) { - if _, err := GetSpace(workdir, spaceID); err != nil { - return nil, err - } - if strings.TrimSpace(d.Decision) == "" { - return nil, fmt.Errorf("continuity: decision is required") - } - now := time.Now().UTC() - if d.ID == "" { - d.ID = "dec-" + now.Format("20060102-150405") - } - if d.Scope == "" { - d.Scope = "space" - } - if d.Status == "" { - d.Status = "active" - } - if d.Weight == 0 { - d.Weight = 1.0 - } - d.CreatedAt = now - if err := appendJSONL(filepath.Join(SpaceDir(workdir, spaceID), DecisionsFile), d); err != nil { - return nil, err - } - return &d, nil -} - -func RecordFeedback(workdir, spaceID string, f Feedback) (*Feedback, error) { - if _, err := GetSpace(workdir, spaceID); err != nil { - return nil, err - } - if strings.TrimSpace(f.Signal) == "" { - return nil, fmt.Errorf("continuity: signal is required") - } - now := time.Now().UTC() - if f.ID == "" { - f.ID = "fb-" + now.Format("20060102-150405") - } - if f.Source == "" { - f.Source = "user" - } - f.CreatedAt = now - if err := appendJSONL(filepath.Join(SpaceDir(workdir, spaceID), FeedbackFile), f); err != nil { - return nil, err - } - return &f, nil -} - -func RecordMemoryItem(workdir, spaceID string, item MemoryItem) (*MemoryItem, error) { - if _, err := GetSpace(workdir, spaceID); err != nil { - return nil, err - } - if strings.TrimSpace(item.Content) == "" { - return nil, fmt.Errorf("continuity: memory content is required") - } - now := time.Now().UTC() - if item.ID == "" { - item.ID = "mem-" + now.Format("20060102-150405") - } - if err := ValidateID(item.ID); err != nil { - return nil, err - } - if item.Kind == "" { - item.Kind = "observation" - } - if item.Source == "" { - item.Source = "model" - } - if item.Status == "" { - item.Status = "provisional" - } - if item.Weight == 0 { - item.Weight = 0.5 - } - item.CreatedAt = now - item.UpdatedAt = now - if err := appendJSONL(filepath.Join(SpaceDir(workdir, spaceID), MemoryItemsFile), item); err != nil { - return nil, err - } - return &item, nil -} - -func PromoteMemoryItem(workdir, spaceID string, p MemoryPromotion) (*Decision, error) { - if strings.TrimSpace(p.ItemID) == "" { - return nil, fmt.Errorf("continuity: memory item_id is required") - } - if strings.TrimSpace(p.Decision) == "" { - return nil, fmt.Errorf("continuity: promotion decision is required") - } - reason := strings.TrimSpace(p.Reason) - if reason == "" { - reason = "Promoted from memory item " + p.ItemID - } - return RecordDecision(workdir, spaceID, Decision{ - Scope: "memory", - Target: firstNonEmpty(p.Target, p.ItemID), - Decision: p.Decision, - Reason: reason, - Weight: 1.0, - }) -} - -func RecordSpaceCompaction(workdir, spaceID string, c SpaceCompaction) (*SpaceCompaction, error) { - if _, err := GetSpace(workdir, spaceID); err != nil { - return nil, err - } - if strings.TrimSpace(c.Summary) == "" { - return nil, fmt.Errorf("continuity: compaction summary is required") - } - now := time.Now().UTC() - if c.ID == "" { - c.ID = "cmp-" + now.Format("20060102-150405") - } - if err := ValidateID(c.ID); err != nil { - return nil, err - } - if c.Scope == "" { - c.Scope = "space" - } - c.CreatedAt = now - if err := appendJSONL(filepath.Join(SpaceDir(workdir, spaceID), CompactionFile), c); err != nil { - return nil, err - } - return &c, nil -} - -func PlanWorkflow(workdir, intent string) (*WorkflowPlan, error) { - intent = strings.TrimSpace(intent) - hits, err := SearchSpaces(workdir, intent) - if err != nil { - return nil, err - } - p := &WorkflowPlan{ - Intent: intent, - Mode: "new_space", - Reason: "No matching active creative space was found; start with provisional assumptions and ask for confirmation.", - Steps: []WorkflowStep{ - {ID: "find-context", Action: "search existing spaces", Tool: "list_spaces", Reason: "Avoid creating duplicate continuity spaces."}, - {ID: "draft-space", Action: "create draft space", Tool: "create_space", Reason: "Store provisional assumptions without polluting canon."}, - {ID: "ask-confirmation", Action: "ask concise confirmation questions", Reason: "Confirmed direction is required before durable episodes or decisions."}, - }, - NeedsConfirmation: true, - } - if len(hits) == 0 { - return p, nil - } - best := hits[0].Space - p.SpaceID = best.ID - if best.Status == "draft" { - p.Mode = "confirm_space" - p.Reason = "A draft space matches; confirm or correct assumptions before production." - p.Steps = []WorkflowStep{ - {ID: "load-context", Action: "load selected context", Tool: "get_context_packet", Reason: "Review assumptions and open context."}, - {ID: "ask-confirmation", Action: "ask user to confirm or correct core direction", Reason: "Draft spaces cannot create durable episodes."}, - {ID: "activate", Action: "activate after confirmation", Tool: "activate_space", Reason: "Promotion requires explicit confirmation."}, - } - p.NeedsConfirmation = true - return p, nil - } - p.Mode = "continue_space" - p.Reason = "An active creative space matches; load selected context and continue production." - p.Steps = []WorkflowStep{ - {ID: "load-context", Action: "load selected context", Tool: "get_context_packet", Reason: "Reuse canon, decisions, feedback, recent episodes, and ranked assets."}, - {ID: "adapt", Action: "adapt plan using feedback and memory", Reason: "Keep continuity while responding to recent performance or user direction."}, - {ID: "produce", Action: "create or update episode/assets", Tool: "create_episode", Reason: "Record durable production units after context is loaded."}, - {ID: "finish", Action: "summarize updates", Tool: "finish", Reason: "Return concise output and updated continuity state."}, - } - p.NeedsConfirmation = false - return p, nil -} - -func BuildCompactionDraft(workdir, projectID, spaceID string) (string, error) { - p, err := BuildSelectedContextPacket(workdir, projectID, spaceID, SelectionOptions{ - MaxDecisions: 12, - MaxFeedback: 12, - MaxEpisodes: 12, - MaxAssets: 12, - }) - if err != nil { - return "", err - } - var b strings.Builder - fmt.Fprintf(&b, "# %s Compaction\n\n", p.Space.Name) - fmt.Fprintf(&b, "Space: %s (%s)\n", p.Space.ID, p.Space.Status) - if strings.TrimSpace(p.Canon) != "" { - b.WriteString("\n## Canon\n") - b.WriteString(strings.TrimSpace(p.Canon)) - b.WriteString("\n") - } - if len(p.RecentDecisions) > 0 { - b.WriteString("\n## Confirmed Decisions\n") - for _, d := range p.RecentDecisions { - fmt.Fprintf(&b, "- %s", d.Decision) - if d.Target != "" { - fmt.Fprintf(&b, " [%s]", d.Target) - } - b.WriteString("\n") - } - } - if len(p.RecentFeedback) > 0 { - b.WriteString("\n## Feedback Signals\n") - for _, f := range p.RecentFeedback { - fmt.Fprintf(&b, "- %s", f.Signal) - if f.Recommendation != "" { - fmt.Fprintf(&b, ": %s", f.Recommendation) - } - b.WriteString("\n") - } - } - if len(p.Assets) > 0 { - b.WriteString("\n## Reusable Assets\n") - for _, a := range p.Assets { - fmt.Fprintf(&b, "- %s (%s, weight %.2f): %s\n", a.ID, a.Status, a.Weight, a.Description) - } - } - if len(p.RecentEpisodes) > 0 { - b.WriteString("\n## Recent Episodes\n") - for _, ep := range p.RecentEpisodes { - fmt.Fprintf(&b, "- %s: %s\n", ep.ID, firstNonEmpty(ep.Topic, ep.Title)) - } - } - return strings.TrimSpace(b.String()) + "\n", nil -} - -func CreateEpisode(workdir, spaceID string, ep Episode) (*Episode, error) { - sp, err := GetSpace(workdir, spaceID) - if err != nil { - return nil, err - } - if sp.Status == "draft" { - return nil, fmt.Errorf("continuity: space %s is draft; ask the user to confirm core assumptions and activate the space before creating durable episodes", spaceID) - } - if strings.TrimSpace(ep.ID) == "" { - ep.ID = slugFromText(firstNonEmpty(ep.Topic, ep.Title, "episode")) - } - if err := ValidateID(ep.ID); err != nil { - return nil, err - } - if ep.Status == "" { - ep.Status = "draft" - } - now := time.Now().UTC() - ep.CreatedAt = now - ep.UpdatedAt = now - dir := filepath.Join(SpaceDir(workdir, spaceID), EpisodesDirName, ep.ID) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, err - } - if err := writeJSON(filepath.Join(dir, "episode.json"), ep); err != nil { - return nil, err - } - if ep.Brief != "" { - if err := os.WriteFile(filepath.Join(dir, "brief.md"), []byte(ensureNL(ep.Brief)), 0o644); err != nil { - return nil, err - } - } - return &ep, nil -} - -func RegisterAsset(workdir, spaceID string, a Asset) (*Asset, error) { - if _, err := GetSpace(workdir, spaceID); err != nil { - return nil, err - } - if strings.TrimSpace(a.ID) == "" { - a.ID = slugFromText(firstNonEmpty(a.Description, a.Kind, "asset")) - } - if err := ValidateID(a.ID); err != nil { - return nil, err - } - now := time.Now().UTC() - a.SpaceID = spaceID - if a.Status == "" { - a.Status = "active" - } - if a.Weight == 0 { - a.Weight = 1.0 - } - a.CreatedAt = now - a.UpdatedAt = now - dir := filepath.Join(SpaceDir(workdir, spaceID), AssetsDirName, a.ID) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, err - } - if err := writeJSON(filepath.Join(dir, "asset.json"), a); err != nil { - return nil, err - } - return &a, nil -} - -func UpdateAssetWeight(workdir, spaceID, assetID string, weight float64, status string) (*Asset, error) { - if _, err := GetSpace(workdir, spaceID); err != nil { - return nil, err - } - if err := ValidateID(assetID); err != nil { - return nil, err - } - path := filepath.Join(SpaceDir(workdir, spaceID), AssetsDirName, assetID, "asset.json") - var a Asset - if err := readJSON(path, &a); err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: asset %s", ErrNotFound, assetID) - } - return nil, err - } - a.Weight = weight - if strings.TrimSpace(status) != "" { - a.Status = strings.TrimSpace(status) - } - a.UpdatedAt = time.Now().UTC() - if err := writeJSON(path, &a); err != nil { - return nil, err - } - return &a, nil -} - -func BuildContextPacket(workdir, projectID, spaceID string) (*ContextPacket, error) { - return BuildSelectedContextPacket(workdir, projectID, spaceID, SelectionOptions{}) -} - -func BuildSelectedContextPacket(workdir, projectID, spaceID string, opts SelectionOptions) (*ContextPacket, error) { - sp, err := GetSpace(workdir, spaceID) - if err != nil { - return nil, err - } - opts = normalizeSelectionOptions(opts) - p := &ContextPacket{ - ProjectID: projectID, - Authority: "canon and recent_decisions are confirmed/high-authority; assumptions are provisional/low-authority and must be confirmed before becoming long-term rules", - Space: sp, - Selection: &Selection{ - Query: strings.TrimSpace(opts.Query), - DecisionLimit: opts.MaxDecisions, - FeedbackLimit: opts.MaxFeedback, - EpisodeLimit: opts.MaxEpisodes, - AssetLimit: opts.MaxAssets, - Reasons: []string{"canon and decisions have highest authority", "feedback and asset weights influence future production", "recent episodes preserve continuity"}, - }, - } - p.Assumptions, _ = readText(filepath.Join(SpaceDir(workdir, spaceID), AssumptionsFileName)) - p.Canon, _ = readText(filepath.Join(SpaceDir(workdir, spaceID), CanonFileName)) - p.Memory, _ = readText(filepath.Join(SpaceDir(workdir, spaceID), MemoryFileName)) - p.Plan, _ = readText(filepath.Join(SpaceDir(workdir, spaceID), PlanFileName)) - p.RecentDecisions, _ = readJSONL[Decision](filepath.Join(SpaceDir(workdir, spaceID), DecisionsFile), opts.MaxDecisions) - p.RecentFeedback, _ = readJSONL[Feedback](filepath.Join(SpaceDir(workdir, spaceID), FeedbackFile), opts.MaxFeedback) - p.RecentEpisodes, _ = listEpisodes(workdir, spaceID, opts.MaxEpisodes) - p.Assets, _ = listAssets(workdir, spaceID, opts.MaxAssets) - if opts.Query != "" { - p.Assets = rankAssetsForQuery(p.Assets, opts.Query) - } - p.Selection.Truncated = detectTruncation(workdir, spaceID, opts, p) - return p, nil -} - -func normalizeSelectionOptions(opts SelectionOptions) SelectionOptions { - if opts.MaxDecisions <= 0 { - opts.MaxDecisions = 8 - } - if opts.MaxFeedback <= 0 { - opts.MaxFeedback = 8 - } - if opts.MaxEpisodes <= 0 { - opts.MaxEpisodes = 8 - } - if opts.MaxAssets <= 0 { - opts.MaxAssets = 20 - } - return opts -} - -func rankAssetsForQuery(in []Asset, query string) []Asset { - terms := searchTerms(query) - out := append([]Asset(nil), in...) - sort.SliceStable(out, func(i, j int) bool { - si := assetQueryScore(out[i], terms) - sj := assetQueryScore(out[j], terms) - if si != sj { - return si > sj - } - if out[i].Weight != out[j].Weight { - return out[i].Weight > out[j].Weight - } - return out[i].UpdatedAt.After(out[j].UpdatedAt) - }) - return out -} - -func assetQueryScore(a Asset, terms []string) int { - if len(terms) == 0 { - return 0 - } - hay := strings.ToLower(strings.Join([]string{ - a.ID, a.Kind, a.Status, a.Description, a.ReusePolicy, strings.Join(a.Tags, " "), - }, "\n")) - score := 0 - for _, term := range terms { - if strings.Contains(hay, term) { - score += 3 - } - } - if a.Status == "canonical" { - score++ - } - return score -} - -func detectTruncation(workdir, spaceID string, opts SelectionOptions, p *ContextPacket) []string { - var out []string - if countJSONLLines(filepath.Join(SpaceDir(workdir, spaceID), DecisionsFile)) > len(p.RecentDecisions) { - out = append(out, "recent_decisions") - } - if countJSONLLines(filepath.Join(SpaceDir(workdir, spaceID), FeedbackFile)) > len(p.RecentFeedback) { - out = append(out, "recent_feedback") - } - if countDirs(filepath.Join(SpaceDir(workdir, spaceID), EpisodesDirName)) > len(p.RecentEpisodes) { - out = append(out, "recent_episodes") - } - if countDirs(filepath.Join(SpaceDir(workdir, spaceID), AssetsDirName)) > len(p.Assets) { - out = append(out, "assets") - } - return out -} - -func listEpisodes(workdir, spaceID string, limit int) ([]Episode, error) { - root := filepath.Join(SpaceDir(workdir, spaceID), EpisodesDirName) - entries, err := os.ReadDir(root) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var out []Episode - for _, e := range entries { - if !e.IsDir() { - continue - } - var ep Episode - if err := readJSON(filepath.Join(root, e.Name(), "episode.json"), &ep); err == nil { - out = append(out, ep) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) }) - if limit > 0 && len(out) > limit { - out = out[:limit] - } - return out, nil -} - -func listAssets(workdir, spaceID string, limit int) ([]Asset, error) { - root := filepath.Join(SpaceDir(workdir, spaceID), AssetsDirName) - entries, err := os.ReadDir(root) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var out []Asset - for _, e := range entries { - if !e.IsDir() { - continue - } - var a Asset - if err := readJSON(filepath.Join(root, e.Name(), "asset.json"), &a); err == nil { - out = append(out, a) - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].Weight != out[j].Weight { - return out[i].Weight > out[j].Weight - } - return out[i].UpdatedAt.After(out[j].UpdatedAt) - }) - if limit > 0 && len(out) > limit { - out = out[:limit] - } - return out, nil -} - -func writeJSON(path string, v any) error { - b, err := json.MarshalIndent(v, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(b, '\n'), 0o644) -} - -func readJSON(path string, v any) error { - b, err := os.ReadFile(path) - if err != nil { - return err - } - return json.Unmarshal(b, v) -} - -func appendJSONL(path string, v any) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - b, err := json.Marshal(v) - if err != nil { - return err - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return err - } - defer f.Close() - if _, err := f.Write(append(b, '\n')); err != nil { - return err - } - return nil -} - -func readJSONL[T any](path string, limit int) ([]T, error) { - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - lines := strings.Split(strings.TrimSpace(string(b)), "\n") - if len(lines) == 1 && strings.TrimSpace(lines[0]) == "" { - return nil, nil - } - start := 0 - if limit > 0 && len(lines) > limit { - start = len(lines) - limit - } - out := make([]T, 0, len(lines)-start) - for _, line := range lines[start:] { - var v T - if err := json.Unmarshal([]byte(line), &v); err == nil { - out = append(out, v) - } - } - return out, nil -} - -func countJSONLLines(path string) int { - b, err := os.ReadFile(path) - if err != nil { - return 0 - } - n := 0 - for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { - if strings.TrimSpace(line) != "" { - n++ - } - } - return n -} - -func countDirs(path string) int { - entries, err := os.ReadDir(path) - if err != nil { - return 0 - } - n := 0 - for _, e := range entries { - if e.IsDir() { - n++ - } - } - return n -} - -func readText(path string) (string, error) { - b, err := os.ReadFile(path) - if err != nil { - return "", err - } - return string(b), nil -} - -func cleanTags(tags []string) []string { - seen := map[string]bool{} - var out []string - for _, t := range tags { - t = strings.ToLower(strings.TrimSpace(t)) - if t == "" || seen[t] { - continue - } - seen[t] = true - out = append(out, t) - } - return out -} - -func slugFromText(s string) string { - s = strings.ToLower(s) - var b strings.Builder - prevHy := false - for _, r := range s { - switch { - case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': - b.WriteRune(r) - prevHy = false - case r == ' ' || r == '_' || r == '-' || r == '.': - if !prevHy && b.Len() > 0 { - b.WriteByte('-') - prevHy = true - } - } - } - out := strings.Trim(b.String(), "-") - if out == "" || out[0] < 'a' || out[0] > 'z' { - out = "item-" + out - out = strings.TrimRight(out, "-") - } - if len(out) > 64 { - out = strings.TrimRight(out[:64], "-") - } - if len(out) < 2 { - out = "item" - } - return out -} - -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - return "" -} - -func ensureNL(s string) string { - if strings.HasSuffix(s, "\n") { - return s - } - return s + "\n" -} diff --git a/internal/continuity/continuity_test.go b/internal/continuity/continuity_test.go deleted file mode 100644 index 5a6950b..0000000 --- a/internal/continuity/continuity_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package continuity - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -func TestCreateSpaceWritesFiles(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - sp, err := CreateSpace(wd, CreateSpaceOptions{ - ID: "tennis-anime", - Name: "Tennis Anime", - Platform: "short-video", - Audience: "beginners", - Description: "Teach tennis with anime panels.", - Tags: []string{"tennis", "anime", "tennis"}, - Assumptions: "# Assumptions\n\n- Maybe use a playful tone.\n", - }) - if err != nil { - t.Fatalf("CreateSpace: %v", err) - } - if sp.Status != "draft" || len(sp.Tags) != 2 { - t.Fatalf("space fields: %+v", sp) - } - for _, p := range []string{ - SpaceFileName, - AssumptionsFileName, - CanonFileName, - MemoryFileName, - PlanFileName, - filepath.Join(EpisodesDirName), - filepath.Join(AssetsDirName), - } { - if _, err := os.Stat(filepath.Join(SpaceDir(wd, sp.ID), p)); err != nil { - t.Errorf("missing %s: %v", p, err) - } - } - canon, err := ReadCanon(wd, sp.ID) - if err != nil { - t.Fatalf("ReadCanon: %v", err) - } - if strings.Contains(canon, "Keep it playful") { - t.Fatalf("create space should not promote assumptions into canon: %q", canon) - } -} - -func TestSearchSpacesRanksMatches(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := CreateSpace(wd, CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime", Tags: []string{"tennis"}}); err != nil { - t.Fatal(err) - } - if _, err := CreateSpace(wd, CreateSpaceOptions{ID: "food-reviews", Name: "Food Reviews", Tags: []string{"food"}}); err != nil { - t.Fatal(err) - } - hits, err := SearchSpaces(wd, "tennis") - if err != nil { - t.Fatalf("SearchSpaces: %v", err) - } - if len(hits) != 1 || hits[0].Space.ID != "tennis-anime" { - t.Fatalf("unexpected hits: %+v", hits) - } -} - -func TestContextPacketIncludesRecentState(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := CreateSpace(wd, CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatal(err) - } - if _, err := CreateEpisode(wd, "tennis-anime", Episode{ID: "too-soon", Topic: "Too soon"}); err == nil { - t.Fatal("expected draft space to reject durable episode creation") - } - if _, _, err := ActivateSpace(wd, "tennis-anime", Decision{Decision: "User confirmed the core tennis anime direction."}); err != nil { - t.Fatalf("ActivateSpace: %v", err) - } - if _, err := RecordDecision(wd, "tennis-anime", Decision{Decision: "Use clean anime style."}); err != nil { - t.Fatalf("RecordDecision: %v", err) - } - if _, err := RecordFeedback(wd, "tennis-anime", Feedback{Signal: "pace_too_fast"}); err != nil { - t.Fatalf("RecordFeedback: %v", err) - } - if _, err := CreateEpisode(wd, "tennis-anime", Episode{ID: "serve-basics", Topic: "Serve basics", Brief: "Teach serving."}); err != nil { - t.Fatalf("CreateEpisode: %v", err) - } - if _, err := RegisterAsset(wd, "tennis-anime", Asset{ID: "court-bg", Kind: "background", Description: "Default court."}); err != nil { - t.Fatalf("RegisterAsset: %v", err) - } - p, err := BuildContextPacket(wd, "creator", "tennis-anime") - if err != nil { - t.Fatalf("BuildContextPacket: %v", err) - } - if p.Space.ID != "tennis-anime" || p.Space.Status != "active" || p.Assumptions == "" || p.Canon == "" || len(p.RecentDecisions) != 2 || len(p.RecentFeedback) != 1 || len(p.RecentEpisodes) != 1 || len(p.Assets) != 1 { - t.Fatalf("packet missing state: %+v", p) - } -} - -func TestSelectedContextPacketAppliesLimitsAndRanksAssets(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := CreateSpace(wd, CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatal(err) - } - if _, _, err := ActivateSpace(wd, "tennis-anime", Decision{Decision: "confirmed"}); err != nil { - t.Fatal(err) - } - for _, d := range []string{"one", "two", "three"} { - if _, err := RecordDecision(wd, "tennis-anime", Decision{Decision: d}); err != nil { - t.Fatal(err) - } - } - if _, err := RegisterAsset(wd, "tennis-anime", Asset{ID: "generic-room", Description: "plain room", Weight: 10}); err != nil { - t.Fatal(err) - } - if _, err := RegisterAsset(wd, "tennis-anime", Asset{ID: "serve-court", Description: "court for serving drills", Weight: 1, Tags: []string{"serve"}}); err != nil { - t.Fatal(err) - } - p, err := BuildSelectedContextPacket(wd, "creator", "tennis-anime", SelectionOptions{ - Query: "serve lesson", - MaxDecisions: 2, - MaxAssets: 2, - }) - if err != nil { - t.Fatalf("BuildSelectedContextPacket: %v", err) - } - if len(p.RecentDecisions) != 2 || p.Selection.DecisionLimit != 2 { - t.Fatalf("decision limit not applied: %+v", p.Selection) - } - if len(p.Selection.Truncated) == 0 || !containsString(p.Selection.Truncated, "recent_decisions") { - t.Fatalf("expected truncation marker, got %+v", p.Selection.Truncated) - } - if len(p.Assets) < 2 || p.Assets[0].ID != "serve-court" { - t.Fatalf("query asset ranking failed: %+v", p.Assets) - } -} - -func TestMemoryItemPromotionCreatesDecision(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := CreateSpace(wd, CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatal(err) - } - item, err := RecordMemoryItem(wd, "tennis-anime", MemoryItem{ - ID: "mem-tone", - Content: "Audience likes calmer explanations.", - }) - if err != nil { - t.Fatalf("RecordMemoryItem: %v", err) - } - if item.Status != "provisional" || item.Weight != 0.5 { - t.Fatalf("memory defaults: %+v", item) - } - dec, err := PromoteMemoryItem(wd, "tennis-anime", MemoryPromotion{ - ItemID: "mem-tone", - Decision: "Use calmer explanations for beginners.", - }) - if err != nil { - t.Fatalf("PromoteMemoryItem: %v", err) - } - if dec.Scope != "memory" || dec.Target != "mem-tone" { - t.Fatalf("promotion decision mismatch: %+v", dec) - } -} - -func TestPlanWorkflowModes(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - p, err := PlanWorkflow(wd, "continue tennis") - if err != nil { - t.Fatalf("PlanWorkflow new: %v", err) - } - if p.Mode != "new_space" || !p.NeedsConfirmation { - t.Fatalf("new workflow mismatch: %+v", p) - } - if _, err := CreateSpace(wd, CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime", Tags: []string{"tennis"}}); err != nil { - t.Fatal(err) - } - p, err = PlanWorkflow(wd, "continue tennis") - if err != nil { - t.Fatalf("PlanWorkflow draft: %v", err) - } - if p.Mode != "confirm_space" || p.SpaceID != "tennis-anime" { - t.Fatalf("draft workflow mismatch: %+v", p) - } - if _, _, err := ActivateSpace(wd, "tennis-anime", Decision{Decision: "confirmed"}); err != nil { - t.Fatal(err) - } - p, err = PlanWorkflow(wd, "continue tennis") - if err != nil { - t.Fatalf("PlanWorkflow active: %v", err) - } - if p.Mode != "continue_space" || p.NeedsConfirmation { - t.Fatalf("active workflow mismatch: %+v", p) - } -} - -func TestCompactionDraftAndRecord(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := CreateSpace(wd, CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatal(err) - } - if _, _, err := ActivateSpace(wd, "tennis-anime", Decision{Decision: "confirmed tennis anime"}); err != nil { - t.Fatal(err) - } - body, err := BuildCompactionDraft(wd, "creator", "tennis-anime") - if err != nil { - t.Fatalf("BuildCompactionDraft: %v", err) - } - if !strings.Contains(body, "Confirmed Decisions") || !strings.Contains(body, "confirmed tennis anime") { - t.Fatalf("draft missing decision: %s", body) - } - c, err := RecordSpaceCompaction(wd, "tennis-anime", SpaceCompaction{Summary: "Stable anime tennis series."}) - if err != nil { - t.Fatalf("RecordSpaceCompaction: %v", err) - } - if c.Scope != "space" || c.ID == "" { - t.Fatalf("compaction defaults: %+v", c) - } -} - -func containsString(vals []string, want string) bool { - for _, v := range vals { - if v == want { - return true - } - } - return false -} diff --git a/internal/generation/llm_provider.go b/internal/generation/llm_provider.go deleted file mode 100644 index b94e2c5..0000000 --- a/internal/generation/llm_provider.go +++ /dev/null @@ -1,88 +0,0 @@ -package generation - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/eight-acres-lab/openmelon/internal/llm" -) - -// LLMProvider implements Provider by calling an llm.Client. -// The compiled prompt is sent as the System role; the request Intent as the User role. -type LLMProvider struct { - client llm.Client -} - -// NewLLMProvider returns a LLMProvider backed by the given client. -func NewLLMProvider(client llm.Client) *LLMProvider { - return &LLMProvider{client: client} -} - -// Generate sends req.Prompt as the system prompt and req.Intent as the user message -// to the LLM client. When stdout is a TTY the response is streamed to stderr so the -// user sees progress. Non-TTY (CI, pipes) falls back to a single blocking Complete call. -func (p *LLMProvider) Generate(ctx context.Context, req *Request) (string, *Trace, error) { - start := time.Now() - - userMsg := req.Intent - if userMsg == "" { - fmt.Fprintln(os.Stderr, "warning: --intent is empty; using placeholder text for LLM user message") - userMsg = "(no intent provided)" - } - - opts := llm.CompleteOptions{ - System: req.Prompt, - User: userMsg, - } - - var content string - var err error - - if isTTY() { - content, err = p.client.Stream(ctx, opts, func(delta string) { - os.Stderr.WriteString(delta) //nolint:errcheck - }) - } else { - content, err = p.client.Complete(ctx, opts) - } - - if err != nil { - if ctx.Err() != nil { - return "", nil, &ProviderError{ - Code: "timeout", - Message: fmt.Sprintf("llm: request timed out (%s)", p.client.Provider()), - Wrapped: ctx.Err(), - } - } - return "", nil, &ProviderError{ - Code: "llm_error", - Message: fmt.Sprintf("llm: %s returned error: %v", p.client.Provider(), err), - Wrapped: err, - } - } - - if content == "" { - return "", nil, &ProviderError{ - Code: "empty_output", - Message: fmt.Sprintf("llm: %s/%s returned empty content", p.client.Provider(), p.client.Model()), - } - } - - trace := &Trace{ - ProviderType: "llm", - Model: p.client.Model(), - DurationSec: time.Since(start).Seconds(), - } - return content, trace, nil -} - -// isTTY reports whether stdout is a character device (interactive terminal). -func isTTY() bool { - info, err := os.Stdout.Stat() - if err != nil { - return false - } - return info.Mode()&os.ModeCharDevice != 0 -} diff --git a/internal/generation/provider.go b/internal/generation/provider.go deleted file mode 100644 index 235c408..0000000 --- a/internal/generation/provider.go +++ /dev/null @@ -1,27 +0,0 @@ -package generation - -import "context" - -// Provider executes a generation request and returns artifact content and a trace. -type Provider interface { - Generate(ctx context.Context, req *Request) (content string, trace *Trace, err error) -} - -// Trace records how an artifact was produced (included in provenance). -type Trace struct { - ProviderType string `json:"provider_type"` - Model string `json:"model,omitempty"` - Command string `json:"command,omitempty"` - DurationSec float64 `json:"duration_sec"` -} - -// ProviderError is a typed error from a Provider.Generate call. -type ProviderError struct { - // Code is a machine-readable error category: "timeout", "non_zero_exit", "empty_output". - Code string - Message string - Wrapped error -} - -func (e *ProviderError) Error() string { return e.Message } -func (e *ProviderError) Unwrap() error { return e.Wrapped } diff --git a/internal/generation/request.go b/internal/generation/request.go deleted file mode 100644 index f90b248..0000000 --- a/internal/generation/request.go +++ /dev/null @@ -1,11 +0,0 @@ -package generation - -// Request describes a generation request for a model or tool adapter. -type Request struct { - ArtifactType string - Prompt string - Model string - Params map[string]string - // Intent is the operator's free-text intent, used as the LLM User message. - Intent string -} diff --git a/internal/generation/shell_provider.go b/internal/generation/shell_provider.go deleted file mode 100644 index 36de234..0000000 --- a/internal/generation/shell_provider.go +++ /dev/null @@ -1,74 +0,0 @@ -package generation - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "strings" - "time" -) - -// ShellProvider implements Provider by running a shell command. -// The request Prompt is written to the command's stdin; stdout is returned as content. -type ShellProvider struct { - // Command is the executable and any fixed arguments (space-separated). - Command string - // Model is recorded in the Trace for provenance purposes. - Model string - // Env contains additional environment variables merged with os.Environ(). - Env map[string]string -} - -// Generate runs the configured Command, writes req.Prompt to stdin, -// and returns stdout as the artifact content. -func (p *ShellProvider) Generate(ctx context.Context, req *Request) (string, *Trace, error) { - start := time.Now() - - parts := strings.Fields(p.Command) - if len(parts) == 0 { - return "", nil, &ProviderError{Code: "invalid_command", Message: "ShellProvider.Command is empty"} - } - - cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) - cmd.Stdin = strings.NewReader(req.Prompt) - - env := os.Environ() - for k, v := range p.Env { - env = append(env, k+"="+v) - } - cmd.Env = env - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if ctx.Err() != nil { - return "", nil, &ProviderError{ - Code: "timeout", - Message: fmt.Sprintf("command timed out: %s", p.Command), - Wrapped: ctx.Err(), - } - } - detail := strings.TrimSpace(stderr.String()) - if detail == "" { - detail = err.Error() - } - return "", nil, &ProviderError{ - Code: "non_zero_exit", - Message: fmt.Sprintf("command failed: %s", detail), - Wrapped: err, - } - } - - content := strings.TrimRight(stdout.String(), "\n") - trace := &Trace{ - ProviderType: "shell", - Model: p.Model, - Command: p.Command, - DurationSec: time.Since(start).Seconds(), - } - return content, trace, nil -} diff --git a/internal/generation/shell_provider_test.go b/internal/generation/shell_provider_test.go deleted file mode 100644 index cd60076..0000000 --- a/internal/generation/shell_provider_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package generation - -import ( - "context" - "errors" - "strings" - "testing" - "time" -) - -func TestShellProvider_success(t *testing.T) { - p := &ShellProvider{Command: "echo hello", Model: "test-model"} - req := &Request{Prompt: "some prompt", ArtifactType: "image_prompt"} - - content, trace, err := p.Generate(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if content != "hello" { - t.Errorf("content = %q, want %q", content, "hello") - } - if trace == nil { - t.Fatal("trace is nil") - } - if trace.ProviderType != "shell" { - t.Errorf("trace.ProviderType = %q, want %q", trace.ProviderType, "shell") - } - if trace.DurationSec < 0 { - t.Errorf("trace.DurationSec = %f, want >= 0", trace.DurationSec) - } -} - -func TestShellProvider_nonZeroExit(t *testing.T) { - // "false" command always exits with code 1 - p := &ShellProvider{Command: "false"} - req := &Request{Prompt: "prompt"} - - _, _, err := p.Generate(context.Background(), req) - if err == nil { - t.Fatal("expected error, got nil") - } - - var provErr *ProviderError - if !errors.As(err, &provErr) { - t.Fatalf("expected *ProviderError, got %T: %v", err, err) - } - if provErr.Code != "non_zero_exit" { - t.Errorf("Code = %q, want %q", provErr.Code, "non_zero_exit") - } -} - -func TestShellProvider_contextTimeout(t *testing.T) { - // "sleep 10" will be killed by the context timeout - p := &ShellProvider{Command: "sleep 10"} - req := &Request{Prompt: "prompt"} - - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - _, _, err := p.Generate(ctx, req) - if err == nil { - t.Fatal("expected error, got nil") - } - - var provErr *ProviderError - if !errors.As(err, &provErr) { - t.Fatalf("expected *ProviderError, got %T: %v", err, err) - } - if provErr.Code != "timeout" { - t.Errorf("Code = %q, want %q", provErr.Code, "timeout") - } - if !strings.Contains(provErr.Message, "timed out") { - t.Errorf("expected 'timed out' in message, got: %q", provErr.Message) - } -} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go deleted file mode 100644 index b76af1c..0000000 --- a/internal/hooks/hooks.go +++ /dev/null @@ -1,198 +0,0 @@ -package hooks - -import ( - "context" - "encoding/json" - - "github.com/eight-acres-lab/openmelon/internal/llm" -) - -type Decision string - -const ( - Allow Decision = "allow" - Deny Decision = "deny" - Cancel Decision = "cancel" -) - -type HookResult struct { - Decision Decision - Reason string - AppendUserFeedback []string - RewriteToolArguments json.RawMessage - RewriteContinuityPayload json.RawMessage -} - -func (r HookResult) EffectiveDecision() Decision { - if r.Decision == "" { - return Allow - } - return r.Decision -} - -type Manager interface { - BeforeModelRequest(context.Context, ModelRequestEvent) HookResult - AfterModelResponse(context.Context, ModelResponseEvent) HookResult - BeforeToolCall(context.Context, ToolCallEvent) HookResult - AfterToolCall(context.Context, ToolResultEvent) HookResult - BeforeContinuityWrite(context.Context, ContinuityWriteEvent) HookResult - AfterContinuityWrite(context.Context, ContinuityWriteEvent) HookResult -} - -type NoopManager struct{} - -func (NoopManager) BeforeModelRequest(context.Context, ModelRequestEvent) HookResult { - return HookResult{} -} -func (NoopManager) AfterModelResponse(context.Context, ModelResponseEvent) HookResult { - return HookResult{} -} -func (NoopManager) BeforeToolCall(context.Context, ToolCallEvent) HookResult { return HookResult{} } -func (NoopManager) AfterToolCall(context.Context, ToolResultEvent) HookResult { return HookResult{} } -func (NoopManager) BeforeContinuityWrite(context.Context, ContinuityWriteEvent) HookResult { - return HookResult{} -} -func (NoopManager) AfterContinuityWrite(context.Context, ContinuityWriteEvent) HookResult { - return HookResult{} -} - -type Chain []Manager - -func ChainManagers(managers ...Manager) Manager { - var out Chain - for _, m := range managers { - if m != nil { - out = append(out, m) - } - } - if len(out) == 0 { - return nil - } - if len(out) == 1 { - return out[0] - } - return out -} - -func (c Chain) BeforeModelRequest(ctx context.Context, e ModelRequestEvent) HookResult { - var merged HookResult - for _, m := range c { - r := m.BeforeModelRequest(ctx, e) - merged.AppendUserFeedback = append(merged.AppendUserFeedback, r.AppendUserFeedback...) - if stop := mergeDecision(&merged, r); stop { - return merged - } - } - return merged -} - -func (c Chain) AfterModelResponse(ctx context.Context, e ModelResponseEvent) HookResult { - var merged HookResult - for _, m := range c { - r := m.AfterModelResponse(ctx, e) - merged.AppendUserFeedback = append(merged.AppendUserFeedback, r.AppendUserFeedback...) - if stop := mergeDecision(&merged, r); stop { - return merged - } - } - return merged -} - -func (c Chain) BeforeToolCall(ctx context.Context, e ToolCallEvent) HookResult { - var merged HookResult - for _, m := range c { - r := m.BeforeToolCall(ctx, e) - if len(r.RewriteToolArguments) > 0 { - merged.RewriteToolArguments = r.RewriteToolArguments - e.Call.Arguments = r.RewriteToolArguments - } - merged.AppendUserFeedback = append(merged.AppendUserFeedback, r.AppendUserFeedback...) - if stop := mergeDecision(&merged, r); stop { - return merged - } - } - return merged -} - -func (c Chain) AfterToolCall(ctx context.Context, e ToolResultEvent) HookResult { - var merged HookResult - for _, m := range c { - r := m.AfterToolCall(ctx, e) - merged.AppendUserFeedback = append(merged.AppendUserFeedback, r.AppendUserFeedback...) - if stop := mergeDecision(&merged, r); stop { - return merged - } - } - return merged -} - -func (c Chain) BeforeContinuityWrite(ctx context.Context, e ContinuityWriteEvent) HookResult { - var merged HookResult - for _, m := range c { - r := m.BeforeContinuityWrite(ctx, e) - if len(r.RewriteContinuityPayload) > 0 { - merged.RewriteContinuityPayload = r.RewriteContinuityPayload - e.Payload = r.RewriteContinuityPayload - } - merged.AppendUserFeedback = append(merged.AppendUserFeedback, r.AppendUserFeedback...) - if stop := mergeDecision(&merged, r); stop { - return merged - } - } - return merged -} - -func (c Chain) AfterContinuityWrite(ctx context.Context, e ContinuityWriteEvent) HookResult { - var merged HookResult - for _, m := range c { - r := m.AfterContinuityWrite(ctx, e) - merged.AppendUserFeedback = append(merged.AppendUserFeedback, r.AppendUserFeedback...) - if stop := mergeDecision(&merged, r); stop { - return merged - } - } - return merged -} - -func mergeDecision(dst *HookResult, src HookResult) bool { - switch src.EffectiveDecision() { - case Deny, Cancel: - dst.Decision = src.EffectiveDecision() - dst.Reason = src.Reason - return true - default: - return false - } -} - -type ModelRequestEvent struct { - Step int - Messages []llm.Message - Tools []llm.Tool -} - -type ModelResponseEvent struct { - Step int - Response llm.ChatResponse -} - -type ToolCallEvent struct { - Step int - Call llm.ToolCall -} - -type ToolResultEvent struct { - Step int - Call llm.ToolCall - Content string - Err error -} - -type ContinuityWriteEvent struct { - Tool string - Workdir string - SpaceID string - Payload json.RawMessage - Result any - Err error -} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go deleted file mode 100644 index 7e84a14..0000000 --- a/internal/hooks/hooks_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package hooks - -import ( - "context" - "encoding/json" - "testing" -) - -func TestChainManagersMergesFeedbackAndRewrites(t *testing.T) { - h := ChainManagers( - testHook{feedback: "first"}, - testHook{rewriteTool: json.RawMessage(`{"text":"rewritten"}`), feedback: "second"}, - ) - got := h.BeforeToolCall(context.Background(), ToolCallEvent{}) - if string(got.RewriteToolArguments) != `{"text":"rewritten"}` { - t.Fatalf("rewrite = %s", got.RewriteToolArguments) - } - if len(got.AppendUserFeedback) != 2 || got.AppendUserFeedback[0] != "first" || got.AppendUserFeedback[1] != "second" { - t.Fatalf("feedback = %+v", got.AppendUserFeedback) - } -} - -func TestChainManagersStopsOnDeny(t *testing.T) { - called := false - h := ChainManagers( - testHook{decision: Deny, reason: "blocked"}, - testHook{onBeforeTool: func() { called = true }}, - ) - got := h.BeforeToolCall(context.Background(), ToolCallEvent{}) - if got.EffectiveDecision() != Deny || got.Reason != "blocked" { - t.Fatalf("result = %+v", got) - } - if called { - t.Fatal("chain continued after deny") - } -} - -type testHook struct { - NoopManager - decision Decision - reason string - feedback string - rewriteTool json.RawMessage - onBeforeTool func() -} - -func (h testHook) BeforeToolCall(context.Context, ToolCallEvent) HookResult { - if h.onBeforeTool != nil { - h.onBeforeTool() - } - var fb []string - if h.feedback != "" { - fb = []string{h.feedback} - } - return HookResult{ - Decision: h.decision, - Reason: h.reason, - AppendUserFeedback: fb, - RewriteToolArguments: h.rewriteTool, - } -} diff --git a/internal/imagegen/factory.go b/internal/imagegen/factory.go deleted file mode 100644 index 6c264b2..0000000 --- a/internal/imagegen/factory.go +++ /dev/null @@ -1,22 +0,0 @@ -package imagegen - -import "fmt" - -// New returns a Generator for the requested provider. -// -// provider must be one of: "openai", "openrouter". -// apiKey, baseURL, defaultModel are all optional — empty values fall -// back to per-provider env vars (OPENAI_API_KEY+OPENAI_BASE_URL or -// OPENROUTER_API_KEY+OPENROUTER_BASE_URL) and built-in defaults. -// -// defaultModel is REQUIRED — no vendor model defaults are baked in. -func New(provider, apiKey, baseURL, defaultModel string) (Generator, error) { - switch provider { - case "openai", "": - return NewOpenAI(apiKey, baseURL, defaultModel) - case "openrouter": - return NewOpenRouter(apiKey, baseURL, defaultModel) - default: - return nil, fmt.Errorf("imagegen: unknown provider %q (supported: openai, openrouter)", provider) - } -} diff --git a/internal/imagegen/imagegen.go b/internal/imagegen/imagegen.go deleted file mode 100644 index a826c3c..0000000 --- a/internal/imagegen/imagegen.go +++ /dev/null @@ -1,225 +0,0 @@ -// Package imagegen wraps OpenAI's image generation endpoint. -// -// Today only OpenAI is supported because that's what the food-street-realism -// reference package targets (gpt-image-family). Stability / Midjourney / -// other vendors slot in as additional Generator implementations behind the -// same interface; the agent loop will dispatch by the package's -// model_profile. -// -// We use only net/http + encoding/json so consumers don't drag in an SDK. -package imagegen - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "time" -) - -// ErrNoAPIKey is returned when no key is supplied AND the env fallback -// is empty. -var ErrNoAPIKey = errors.New("imagegen: no API key supplied and OPENAI_API_KEY env is empty") - -// ErrModelRequired is returned when no model id is passed. -var ErrModelRequired = errors.New("imagegen: no model id supplied — pass --image-model") - -// Generator generates a single image from a text prompt. -// -// Returns the raw image bytes (PNG today; future vendors may return -// other formats — check ContentType on the result for the actual MIME). -type Generator interface { - Generate(ctx context.Context, opts GenerateOptions) (*Result, error) - Provider() string - Model() string -} - -// GenerateOptions describes a single generation. -type GenerateOptions struct { - // Prompt is the image-generation instruction. Required. - Prompt string - - // Model overrides the generator's default. Empty → generator default. - Model string - - // Size in WxH. Empty → "1024x1024". Not all vendors accept arbitrary - // sizes; consult the vendor docs. - Size string - - // N is the number of images to generate. Today only N=1 is supported - // (the agent loop runs multiple Generate calls when it wants variants - // rather than asking the vendor for a batch). - N int - - // ReferenceImages, if non-empty, are passed to the model as input - // images alongside the text prompt. Used for character / reference - // consistency: pass a portrait, ask the model to keep that character. - // - // Provider support varies — OpenRouter chat-completions models - // (Gemini image, GPT-5 image) accept them via the content array; - // the OpenAI /v1/images/generations endpoint does not (use - // /v1/images/edits for that, which ImageEditor wires when needed). - // A generator that ignores ReferenceImages should still succeed — - // callers may want a text-only fallback. - ReferenceImages [][]byte -} - -// Result is a single generated image. -type Result struct { - Data []byte - ContentType string // e.g. "image/png" - Provider string - Model string - Prompt string // echoed back so callers can record provenance - SizeBytes int -} - -// OpenAIGenerator implements Generator against OpenAI's images/generations -// endpoint. -type OpenAIGenerator struct { - apiKey string - baseURL string - defaultModel string - httpClient *http.Client -} - -const ( - openaiDefaultBaseURL = "https://api.openai.com" -) - -// NewOpenAI builds an OpenAIGenerator. -// -// Env-var fallbacks (only used when the matching argument is ""): -// - apiKey ← OPENAI_API_KEY -// - baseURL ← OPENAI_BASE_URL (host only, no /v1 suffix). Useful for -// ChatGPT-Plus relays, LiteLLM, Helicone, or any OpenAI-compatible host. -// -// defaultModel: required — pass an explicit model id (e.g. "gpt-image-1", -// "dall-e-3"). We do not bake in vendor model defaults. -func NewOpenAI(apiKey, baseURL, defaultModel string) (*OpenAIGenerator, error) { - if apiKey == "" { - apiKey = os.Getenv("OPENAI_API_KEY") - } - if apiKey == "" { - return nil, ErrNoAPIKey - } - if baseURL == "" { - baseURL = os.Getenv("OPENAI_BASE_URL") - } - if baseURL == "" { - baseURL = openaiDefaultBaseURL - } - if defaultModel == "" { - return nil, ErrModelRequired - } - return &OpenAIGenerator{ - apiKey: apiKey, - baseURL: baseURL, - defaultModel: defaultModel, - // Image generation is slow + the request body can be several - // MB — give the timeout room and force fresh connections per - // call so stale keep-alive doesn't surface as "tls: bad - // record MAC". - httpClient: &http.Client{ - Timeout: 5 * time.Minute, - Transport: freshTransport(), - }, - }, nil -} - -// BaseURL returns the resolved base URL for telemetry / debugging. -func (g *OpenAIGenerator) BaseURL() string { return g.baseURL } - -func (g *OpenAIGenerator) Provider() string { return "openai" } -func (g *OpenAIGenerator) Model() string { return g.defaultModel } - -type openaiImageRequest struct { - Model string `json:"model"` - Prompt string `json:"prompt"` - Size string `json:"size,omitempty"` - N int `json:"n,omitempty"` -} - -type openaiImageData struct { - B64JSON string `json:"b64_json"` -} - -type openaiImageResponse struct { - Data []openaiImageData `json:"data"` -} - -// Generate sends a single image-generation request and returns the -// decoded image bytes. -func (g *OpenAIGenerator) Generate(ctx context.Context, opts GenerateOptions) (*Result, error) { - if opts.Prompt == "" { - return nil, fmt.Errorf("imagegen[openai]: Prompt is required") - } - model := opts.Model - if model == "" { - model = g.defaultModel - } - size := opts.Size - if size == "" { - size = "1024x1024" - } - n := opts.N - if n == 0 { - n = 1 - } - if n != 1 { - return nil, fmt.Errorf("imagegen[openai]: only N=1 is supported today (got %d)", n) - } - - body, err := json.Marshal(openaiImageRequest{Model: model, Prompt: opts.Prompt, Size: size, N: n}) - if err != nil { - return nil, fmt.Errorf("imagegen[openai]: marshal: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.baseURL+"/v1/images/generations", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("imagegen[openai]: build request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+g.apiKey) - req.Header.Set("content-type", "application/json") - - resp, err := transientHTTPDo(ctx, g.httpClient, req, body, 3) - if err != nil { - return nil, fmt.Errorf("imagegen[openai]: HTTP: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("imagegen[openai]: read response: %w", err) - } - if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("imagegen[openai]: HTTP %d: %s", resp.StatusCode, string(respBody)) - } - - var parsed openaiImageResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - return nil, fmt.Errorf("imagegen[openai]: parse response: %w (body: %s)", err, string(respBody)) - } - if len(parsed.Data) == 0 || parsed.Data[0].B64JSON == "" { - return nil, fmt.Errorf("imagegen[openai]: empty data in response (body: %s)", string(respBody)) - } - - imgBytes, err := base64.StdEncoding.DecodeString(parsed.Data[0].B64JSON) - if err != nil { - return nil, fmt.Errorf("imagegen[openai]: decode b64: %w", err) - } - - return &Result{ - Data: imgBytes, - ContentType: "image/png", - Provider: "openai", - Model: model, - Prompt: opts.Prompt, - SizeBytes: len(imgBytes), - }, nil -} diff --git a/internal/imagegen/imagegen_test.go b/internal/imagegen/imagegen_test.go deleted file mode 100644 index aab2910..0000000 --- a/internal/imagegen/imagegen_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package imagegen - -import ( - "context" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestNewOpenAI_NoKey(t *testing.T) { - t.Setenv("OPENAI_API_KEY", "") - if _, err := NewOpenAI("", "", ""); err != ErrNoAPIKey { - t.Fatalf("expected ErrNoAPIKey, got %v", err) - } -} - -func TestOpenAI_Generate_HappyPath(t *testing.T) { - pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/images/generations" { - t.Errorf("unexpected path %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "Bearer test-key" { - t.Errorf("missing auth header") - } - - var req openaiImageRequest - body, _ := io.ReadAll(r.Body) - if err := json.Unmarshal(body, &req); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if req.Model != "gpt-image-1" { - t.Errorf("expected default model, got %q", req.Model) - } - if req.Size != "1024x1024" { - t.Errorf("expected default size, got %q", req.Size) - } - if req.Prompt != "a cat" { - t.Errorf("prompt mismatch: %q", req.Prompt) - } - - _ = json.NewEncoder(w).Encode(openaiImageResponse{ - Data: []openaiImageData{{B64JSON: base64.StdEncoding.EncodeToString(pngHeader)}}, - }) - })) - defer server.Close() - - g := &OpenAIGenerator{ - apiKey: "test-key", - baseURL: server.URL, - defaultModel: "gpt-image-1", - httpClient: server.Client(), - } - res, err := g.Generate(context.Background(), GenerateOptions{Prompt: "a cat"}) - if err != nil { - t.Fatalf("Generate: %v", err) - } - if res.ContentType != "image/png" { - t.Errorf("content type %q", res.ContentType) - } - if res.SizeBytes != len(pngHeader) { - t.Errorf("size %d, want %d", res.SizeBytes, len(pngHeader)) - } - if string(res.Data[:8]) != string(pngHeader) { - t.Errorf("expected PNG header in data") - } - if res.Provider != "openai" || res.Model != "gpt-image-1" { - t.Errorf("provenance fields wrong: %s / %s", res.Provider, res.Model) - } -} - -func TestOpenAI_Generate_RequiresPrompt(t *testing.T) { - g := &OpenAIGenerator{apiKey: "k", baseURL: "http://x", defaultModel: "x", httpClient: &http.Client{}} - if _, err := g.Generate(context.Background(), GenerateOptions{}); err == nil { - t.Fatal("expected error for empty prompt") - } -} - -func TestOpenAI_Generate_NIsClampedToOne(t *testing.T) { - g := &OpenAIGenerator{apiKey: "k", baseURL: "http://x", defaultModel: "x", httpClient: &http.Client{}} - _, err := g.Generate(context.Background(), GenerateOptions{Prompt: "x", N: 4}) - if err == nil || !strings.Contains(err.Error(), "N=1") { - t.Errorf("expected N=1 restriction, got %v", err) - } -} - -func TestOpenAI_Generate_PropagatesHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"bad prompt"}`)) - })) - defer server.Close() - - g := &OpenAIGenerator{apiKey: "k", baseURL: server.URL, defaultModel: "gpt-image-1", httpClient: server.Client()} - _, err := g.Generate(context.Background(), GenerateOptions{Prompt: "x"}) - if err == nil || !strings.Contains(err.Error(), "400") { - t.Errorf("expected 400 error, got %v", err) - } -} - -func TestProviderAndModel(t *testing.T) { - g := &OpenAIGenerator{defaultModel: "gpt-image-1"} - if g.Provider() != "openai" || g.Model() != "gpt-image-1" { - t.Errorf("accessors wrong: %s / %s", g.Provider(), g.Model()) - } -} - -func TestOpenAI_BaseURL_FromExplicit(t *testing.T) { - t.Setenv("OPENAI_BASE_URL", "") - g, err := NewOpenAI("k", "https://relay.example.com", "gpt-x") - if err != nil { - t.Fatal(err) - } - if g.BaseURL() != "https://relay.example.com" { - t.Errorf("expected explicit base URL, got %q", g.BaseURL()) - } -} - -func TestOpenAI_BaseURL_FromEnv(t *testing.T) { - t.Setenv("OPENAI_BASE_URL", "https://env-relay.example.com") - g, err := NewOpenAI("k", "", "gpt-x") - if err != nil { - t.Fatal(err) - } - if g.BaseURL() != "https://env-relay.example.com" { - t.Errorf("expected env base URL, got %q", g.BaseURL()) - } -} - -func TestOpenAI_BaseURL_Default(t *testing.T) { - t.Setenv("OPENAI_BASE_URL", "") - g, err := NewOpenAI("k", "", "gpt-x") - if err != nil { - t.Fatal(err) - } - if g.BaseURL() != "https://api.openai.com" { - t.Errorf("expected default base URL, got %q", g.BaseURL()) - } -} diff --git a/internal/imagegen/openrouter.go b/internal/imagegen/openrouter.go deleted file mode 100644 index 3c604f3..0000000 --- a/internal/imagegen/openrouter.go +++ /dev/null @@ -1,299 +0,0 @@ -package imagegen - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" -) - -// OpenRouter image generation client. -// -// Unlike the official OpenAI image API which has a dedicated -// /v1/images/generations endpoint, OpenRouter exposes image-capable -// models through /v1/chat/completions with modalities=["image","text"]. -// The model returns base64-encoded PNGs in choices[0].message.images[]. -// -// Image-capable models on OpenRouter today (2026-05): -// - openai/gpt-5-image, openai/gpt-5-image-mini, openai/gpt-5.4-image-2 -// - google/gemini-2.5-flash-image (cheapest) -// - google/gemini-3-pro-image-preview, google/gemini-3.1-flash-image-preview -// -// We use only net/http + encoding/json — same zero-dep posture as the -// rest of internal/imagegen. - -const openRouterDefaultBaseURL = "https://openrouter.ai/api" - -// OpenRouterGenerator implements Generator against OpenRouter's -// chat-completions image generation surface. -type OpenRouterGenerator struct { - apiKey string - baseURL string - defaultModel string - httpClient *http.Client -} - -// NewOpenRouter builds an OpenRouterGenerator. -// -// Env-var fallbacks (only used when the matching argument is ""): -// - apiKey ← OPENROUTER_API_KEY -// - baseURL ← OPENROUTER_BASE_URL -// -// defaultModel: required — pass an explicit model id (e.g. -// "google/gemini-2.5-flash-image" or "openai/gpt-5-image"). We do not -// bake in a default because the cheapest-vs-best model menu evolves. -func NewOpenRouter(apiKey, baseURL, defaultModel string) (*OpenRouterGenerator, error) { - if apiKey == "" { - apiKey = os.Getenv("OPENROUTER_API_KEY") - } - if apiKey == "" { - return nil, ErrNoAPIKey - } - if baseURL == "" { - baseURL = os.Getenv("OPENROUTER_BASE_URL") - } - if baseURL == "" { - baseURL = openRouterDefaultBaseURL - } - if defaultModel == "" { - return nil, ErrModelRequired - } - return &OpenRouterGenerator{ - apiKey: apiKey, - baseURL: baseURL, - defaultModel: defaultModel, - httpClient: &http.Client{ - Timeout: 5 * time.Minute, - Transport: freshTransport(), - }, - }, nil -} - -func (g *OpenRouterGenerator) Provider() string { return "openrouter" } -func (g *OpenRouterGenerator) Model() string { return g.defaultModel } -func (g *OpenRouterGenerator) BaseURL() string { return g.baseURL } - -// orChatRequest is a chat-completions request with the modalities field -// that OpenRouter requires for image generation. -type orChatRequest struct { - Model string `json:"model"` - Messages []orChatMessage `json:"messages"` - Modalities []string `json:"modalities,omitempty"` -} - -// orChatMessage holds either a plain Content string (no reference -// images) or a structured Parts array (text + image_url parts). Wire -// format requires picking exactly one — orChatMessage.MarshalJSON -// handles the dispatch. -type orChatMessage struct { - Role string - Content string - Parts []orContentPart -} - -type orContentPart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ImageURL *orContentPartImageURL `json:"image_url,omitempty"` -} - -type orContentPartImageURL struct { - URL string `json:"url"` -} - -// MarshalJSON picks the right wire shape — plain string when no parts, -// content-array shape when parts are present. Keeps callers simple. -func (m orChatMessage) MarshalJSON() ([]byte, error) { - if len(m.Parts) > 0 { - return json.Marshal(struct { - Role string `json:"role"` - Content []orContentPart `json:"content"` - }{Role: m.Role, Content: m.Parts}) - } - return json.Marshal(struct { - Role string `json:"role"` - Content string `json:"content"` - }{Role: m.Role, Content: m.Content}) -} - -// orChatResponse is the chat-completions response. The image lives in -// choices[0].message.images[0].image_url.url as a data:image/png;base64 -// URL. -type orChatResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - Images []struct { - Type string `json:"type"` - ImageURL struct { - URL string `json:"url"` - } `json:"image_url"` - } `json:"images"` - } `json:"message"` - } `json:"choices"` -} - -// Generate sends a chat-completions request asking the model to -// generate an image, parses the data URL, and returns raw image bytes. -func (g *OpenRouterGenerator) Generate(ctx context.Context, opts GenerateOptions) (*Result, error) { - if opts.Prompt == "" { - return nil, fmt.Errorf("imagegen[openrouter]: Prompt is required") - } - model := opts.Model - if model == "" { - model = g.defaultModel - } - n := opts.N - if n == 0 { - n = 1 - } - if n != 1 { - return nil, fmt.Errorf("imagegen[openrouter]: only N=1 is supported today (got %d)", n) - } - // Note: opts.Size is ignored — OpenRouter's chat-completions image - // surface doesn't accept a size hint; the model picks. If the caller - // needs a specific size, embed it in the prompt itself. - - msg := orChatMessage{Role: "user"} - if len(opts.ReferenceImages) > 0 { - // Reference images go first, then the text prompt — same order - // the multimodal models expect. Each image is encoded as a - // data URL inline; we don't host them anywhere. - for _, img := range opts.ReferenceImages { - ct := sniffImageContentType(img) - msg.Parts = append(msg.Parts, orContentPart{ - Type: "image_url", - ImageURL: &orContentPartImageURL{URL: "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(img)}, - }) - } - msg.Parts = append(msg.Parts, orContentPart{Type: "text", Text: opts.Prompt}) - } else { - msg.Content = opts.Prompt - } - body, err := json.Marshal(orChatRequest{ - Model: model, - Messages: []orChatMessage{msg}, - Modalities: []string{"image", "text"}, - }) - if err != nil { - return nil, fmt.Errorf("imagegen[openrouter]: marshal: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.baseURL+"/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("imagegen[openrouter]: build request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+g.apiKey) - req.Header.Set("content-type", "application/json") - // OpenRouter routing telemetry — optional but recommended. - req.Header.Set("HTTP-Referer", "https://github.com/eight-acres-lab/openmelon") - req.Header.Set("X-Title", "openmelon") - - resp, err := transientHTTPDo(ctx, g.httpClient, req, body, 3) - if err != nil { - return nil, fmt.Errorf("imagegen[openrouter]: HTTP: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("imagegen[openrouter]: read response: %w", err) - } - if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("imagegen[openrouter]: HTTP %d: %s", resp.StatusCode, string(respBody)) - } - - var parsed orChatResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - return nil, fmt.Errorf("imagegen[openrouter]: parse response: %w (body: %s)", err, string(respBody)) - } - if len(parsed.Choices) == 0 { - return nil, fmt.Errorf("imagegen[openrouter]: no choices in response") - } - images := parsed.Choices[0].Message.Images - if len(images) == 0 { - // Sometimes the model refuses to generate; the text content has - // the explanation. Surface it. - txt := strings.TrimSpace(parsed.Choices[0].Message.Content) - if txt != "" { - return nil, fmt.Errorf("imagegen[openrouter]: no image in response (model said: %s)", txt) - } - return nil, fmt.Errorf("imagegen[openrouter]: no image in response (body: %s)", string(respBody)) - } - url := images[0].ImageURL.URL - imgBytes, contentType, err := decodeDataURL(url) - if err != nil { - return nil, fmt.Errorf("imagegen[openrouter]: %w", err) - } - - return &Result{ - Data: imgBytes, - ContentType: contentType, - Provider: "openrouter", - Model: model, - Prompt: opts.Prompt, - SizeBytes: len(imgBytes), - }, nil -} - -// sniffImageContentType returns the MIME type for a small set of common -// image headers. Falls back to "image/png" — the wire spec accepts any -// image/* type, and a wrong-but-recognized MIME hasn't broken Gemini / -// GPT image models in practice. -func sniffImageContentType(b []byte) string { - if len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n" { - return "image/png" - } - if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF { - return "image/jpeg" - } - if len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP" { - return "image/webp" - } - if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") { - return "image/gif" - } - return "image/png" -} - -// decodeDataURL parses a "data:;base64," URL into raw -// bytes + the MIME content type. Returns a clear error for malformed -// URLs. -func decodeDataURL(dataURL string) ([]byte, string, error) { - if !strings.HasPrefix(dataURL, "data:") { - return nil, "", fmt.Errorf("not a data URL") - } - rest := strings.TrimPrefix(dataURL, "data:") - commaIdx := strings.Index(rest, ",") - if commaIdx < 0 { - return nil, "", fmt.Errorf("data URL missing comma separator") - } - header := rest[:commaIdx] - payload := rest[commaIdx+1:] - - // header is ";base64" or "" or ";base64" - contentType := "application/octet-stream" - isBase64 := false - for _, part := range strings.Split(header, ";") { - part = strings.TrimSpace(part) - if part == "base64" { - isBase64 = true - } else if strings.Contains(part, "/") { - contentType = part - } - } - if !isBase64 { - return nil, "", fmt.Errorf("data URL is not base64-encoded (got header: %q)", header) - } - decoded, err := base64.StdEncoding.DecodeString(payload) - if err != nil { - return nil, "", fmt.Errorf("decode base64: %w", err) - } - return decoded, contentType, nil -} diff --git a/internal/imagegen/openrouter_test.go b/internal/imagegen/openrouter_test.go deleted file mode 100644 index c631694..0000000 --- a/internal/imagegen/openrouter_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package imagegen - -import ( - "context" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" -) - -func TestOpenRouter_Generate_NoReferences_PlainStringContent(t *testing.T) { - pngHeader := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - msgs := raw["messages"].([]any) - first := msgs[0].(map[string]any) - // No references → content must be a plain string. - if _, ok := first["content"].(string); !ok { - t.Errorf("expected string content, got %T (%v)", first["content"], first["content"]) - } - - dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngHeader) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []any{ - map[string]any{"message": map[string]any{ - "images": []any{map[string]any{"image_url": map[string]any{"url": dataURL}}}, - }}, - }, - }) - })) - defer server.Close() - - g := &OpenRouterGenerator{apiKey: "k", baseURL: server.URL, defaultModel: "google/gemini-2.5-flash-image", httpClient: server.Client()} - res, err := g.Generate(context.Background(), GenerateOptions{Prompt: "draw a cat"}) - if err != nil { - t.Fatalf("Generate: %v", err) - } - if string(res.Data) != string(pngHeader) { - t.Errorf("data mismatch") - } -} - -func TestOpenRouter_Generate_WithReferences_StructuredContent(t *testing.T) { - pngHeader := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} - jpgHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - msgs := raw["messages"].([]any) - first := msgs[0].(map[string]any) - parts, ok := first["content"].([]any) - if !ok { - t.Fatalf("expected content array, got %T (%v)", first["content"], first["content"]) - } - if len(parts) != 3 { - t.Fatalf("expected 3 parts (2 images + 1 text), got %d", len(parts)) - } - // First two are images, third is text. - if parts[0].(map[string]any)["type"] != "image_url" { - t.Errorf("part[0] not image_url: %v", parts[0]) - } - if parts[1].(map[string]any)["type"] != "image_url" { - t.Errorf("part[1] not image_url: %v", parts[1]) - } - if parts[2].(map[string]any)["type"] != "text" { - t.Errorf("part[2] not text: %v", parts[2]) - } - // Verify content type sniffing put the right MIME on each. - url0 := parts[0].(map[string]any)["image_url"].(map[string]any)["url"].(string) - url1 := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string) - if url0[:len("data:image/png;base64,")] != "data:image/png;base64," { - t.Errorf("first url should be image/png: %q", url0[:30]) - } - if url1[:len("data:image/jpeg;base64,")] != "data:image/jpeg;base64," { - t.Errorf("second url should be image/jpeg: %q", url1[:30]) - } - - dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngHeader) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []any{ - map[string]any{"message": map[string]any{ - "images": []any{map[string]any{"image_url": map[string]any{"url": dataURL}}}, - }}, - }, - }) - })) - defer server.Close() - - g := &OpenRouterGenerator{apiKey: "k", baseURL: server.URL, defaultModel: "google/gemini-2.5-flash-image", httpClient: server.Client()} - res, err := g.Generate(context.Background(), GenerateOptions{ - Prompt: "make Lao Wang stir-fry the noodles", - ReferenceImages: [][]byte{pngHeader, jpgHeader}, - }) - if err != nil { - t.Fatalf("Generate: %v", err) - } - if string(res.Data) != string(pngHeader) { - t.Errorf("data mismatch") - } -} - -func TestSniffImageContentType(t *testing.T) { - cases := []struct { - head []byte - want string - }{ - {[]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, "image/png"}, - {[]byte{0xFF, 0xD8, 0xFF, 0xE0}, "image/jpeg"}, - {append([]byte("RIFF\x00\x00\x00\x00WEBP"), 0), "image/webp"}, - {[]byte("GIF89a..."), "image/gif"}, - {[]byte{0x00, 0x01, 0x02, 0x03}, "image/png"}, // unknown → png fallback - } - for _, c := range cases { - if got := sniffImageContentType(c.head); got != c.want { - t.Errorf("sniffImageContentType(%x): got %q want %q", c.head[:4], got, c.want) - } - } -} diff --git a/internal/imagegen/retry.go b/internal/imagegen/retry.go deleted file mode 100644 index 1990569..0000000 --- a/internal/imagegen/retry.go +++ /dev/null @@ -1,142 +0,0 @@ -package imagegen - -// retry.go — transient-error retry + keep-alive policy for image -// generation HTTP calls. -// -// Why this exists: image generation requests are slow (5-60s) and -// large (up to several MB when reference images are attached). Two -// failure modes are common in practice: -// -// 1. The client reuses a keep-alive connection that the server -// silently closed in the meantime. Go's http.Transport then -// sees garbled bytes and surfaces "tls: bad record MAC" or -// "EOF" — confusing, looks like a TLS bug. -// -// 2. Middleboxes (corporate proxies, VPNs, ISP TLS-inspectors) -// hiccup on multi-MB POST bodies, sometimes returning 5xx or -// dropping the connection mid-stream. -// -// Both fix with: (a) one fresh connection per request (no keep-alive), -// (b) one or two retries with backoff. Combined, ≥95% of these errors -// disappear without changing anything else. - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "net" - "net/http" - "strings" - "time" -) - -// freshTransport returns an http.Transport that doesn't reuse -// connections. Every request opens its own TCP+TLS handshake, which -// avoids the "stale keep-alive" failure mode entirely. Slower, but -// image gen requests are slow anyway — the TCP handshake is -// negligible relative to a 30s model call. -func freshTransport() *http.Transport { - t := http.DefaultTransport.(*http.Transport).Clone() - t.DisableKeepAlives = true - return t -} - -// transientHTTPDo runs req via client with retries on transient -// network errors. Returns the last response (caller closes Body) or -// the last error. -// -// What counts as transient: -// - net.OpError / connection reset / broken pipe -// - "tls: bad record MAC" and other tls.* protocol errors -// - EOF in the middle of reading a response -// - HTTP 502 / 503 / 504 (gateway / upstream errors) -// -// Bodies are buffered before the first attempt so we can replay them -// on retry; callers should pass requests with bytes.Reader bodies (the -// existing imagegen code does). -// -// Backoff: 1s, 2s. With maxAttempts=3 (initial + 2 retries), worst-case -// added latency on a hard failure is 3s + the third attempt's timeout. -func transientHTTPDo(ctx context.Context, client *http.Client, req *http.Request, body []byte, maxAttempts int) (*http.Response, error) { - if maxAttempts < 1 { - maxAttempts = 1 - } - var lastErr error - for attempt := 0; attempt < maxAttempts; attempt++ { - if attempt > 0 { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(time.Duration(attempt) * time.Second): - } - // Refresh the body on retry — the previous attempt - // consumed it. - req.Body = io.NopCloser(bytes.NewReader(body)) - req.GetBody = func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(body)), nil - } - } - resp, err := client.Do(req) - if err != nil { - if isTransientErr(err) { - lastErr = fmt.Errorf("attempt %d: %w", attempt+1, err) - continue - } - return nil, err - } - // Retryable status codes — drain + close body before retry so - // the next attempt's connection (or a fresh one if keep-alive - // is on, which it isn't) is clean. - if isTransientStatus(resp.StatusCode) && attempt < maxAttempts-1 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - lastErr = fmt.Errorf("attempt %d: HTTP %d", attempt+1, resp.StatusCode) - continue - } - return resp, nil - } - return nil, fmt.Errorf("exhausted %d attempts: %w", maxAttempts, lastErr) -} - -func isTransientErr(err error) bool { - if err == nil { - return false - } - if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { - return true - } - var netErr *net.OpError - if errors.As(err, &netErr) { - return true - } - msg := err.Error() - for _, needle := range []string{ - "tls: bad record mac", - "tls: protocol", - "connection reset", - "connection refused", // sometimes intermittent on flaky networks - "broken pipe", - "i/o timeout", - "unexpected eof", - "server closed", - } { - if strings.Contains(strings.ToLower(msg), needle) { - return true - } - } - return false -} - -func isTransientStatus(code int) bool { - switch code { - case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: - return true - case 408: // request timeout - return true - case 429: // rate limit — backoff often helps - return true - } - return false -} diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go deleted file mode 100644 index f144549..0000000 --- a/internal/llm/anthropic.go +++ /dev/null @@ -1,229 +0,0 @@ -package llm - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "time" -) - -// Anthropic Messages API client. -// -// API docs: https://docs.anthropic.com/en/api/messages -// -// We only use the synchronous, non-streaming form — the agent loop does -// streaming at the orchestration layer (multiple sequential Complete calls), -// not at the token level today. - -const ( - anthropicDefaultBaseURL = "https://api.anthropic.com" - anthropicAPIVersion = "2023-06-01" -) - -// AnthropicClient implements Client against Anthropic's Messages API. -type AnthropicClient struct { - apiKey string - baseURL string - defaultModel string - httpClient *http.Client -} - -// NewAnthropic builds an AnthropicClient. -// -// apiKey: explicit key, or "" to read ANTHROPIC_API_KEY from env. -// baseURL: explicit host, or "" to read ANTHROPIC_BASE_URL / use default. -// defaultModel: required — caller must pass an explicit model id (we -// deliberately do not bake in vendor model defaults; the model menu -// changes too often to live in source). -func NewAnthropic(apiKey, baseURL, defaultModel string) (*AnthropicClient, error) { - if apiKey == "" { - apiKey = os.Getenv("ANTHROPIC_API_KEY") - } - if apiKey == "" { - return nil, ErrNoAPIKey - } - if baseURL == "" { - baseURL = os.Getenv("ANTHROPIC_BASE_URL") - } - if baseURL == "" { - baseURL = anthropicDefaultBaseURL - } - if defaultModel == "" { - return nil, ErrModelRequired - } - return &AnthropicClient{ - apiKey: apiKey, - baseURL: baseURL, - defaultModel: defaultModel, - httpClient: &http.Client{Timeout: 120 * time.Second}, - }, nil -} - -func (c *AnthropicClient) Provider() string { return "anthropic" } -func (c *AnthropicClient) Model() string { return c.defaultModel } -func (c *AnthropicClient) BaseURL() string { return c.baseURL } - -type anthropicMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type anthropicRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature"` - System string `json:"system,omitempty"` - Messages []anthropicMessage `json:"messages"` - Stream bool `json:"stream,omitempty"` -} - -type anthropicContentBlock struct { - Type string `json:"type"` - Text string `json:"text"` -} - -type anthropicResponse struct { - Content []anthropicContentBlock `json:"content"` -} - -// Complete sends one user message and returns the model's text reply. -// -// JSONOnly is honored by appending an explicit "Output ONLY a single JSON -// object..." instruction to the system prompt — Anthropic does not have an -// API-level response_format flag the way OpenAI does, but the explicit -// instruction is reliable for Claude 3.5+. -func (c *AnthropicClient) Complete(ctx context.Context, opts CompleteOptions) (string, error) { - return c.doRequest(ctx, opts, nil) -} - -// Stream sends the same request as Complete but with stream=true, parses -// the SSE response, and invokes handler for each text delta. Returns the -// full accumulated text when the stream ends. -func (c *AnthropicClient) Stream(ctx context.Context, opts CompleteOptions, handler StreamHandler) (string, error) { - return c.doRequest(ctx, opts, handler) -} - -// doRequest is the shared implementation. handler==nil means non-streaming. -func (c *AnthropicClient) doRequest(ctx context.Context, opts CompleteOptions, handler StreamHandler) (string, error) { - if opts.User == "" { - return "", fmt.Errorf("llm[anthropic]: User is required") - } - - model := opts.Model - if model == "" { - model = c.defaultModel - } - maxTokens := opts.MaxTokens - if maxTokens == 0 { - maxTokens = 4096 - } - temperature := opts.Temperature - if temperature == 0 { - if opts.JSONOnly { - temperature = 0.2 - } else { - temperature = 0.7 - } - } - - system := opts.System - if opts.JSONOnly { - jsonHint := "\n\nOutput ONLY a single JSON object matching the schema described above. Do not wrap it in markdown fences. Do not add any prose before or after the JSON." - if system == "" { - system = jsonHint - } else { - system = system + jsonHint - } - } - - body, err := json.Marshal(anthropicRequest{ - Model: model, - MaxTokens: maxTokens, - Temperature: temperature, - System: system, - Messages: []anthropicMessage{{Role: "user", Content: opts.User}}, - Stream: handler != nil, - }) - if err != nil { - return "", fmt.Errorf("llm[anthropic]: marshal request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("llm[anthropic]: build request: %w", err) - } - req.Header.Set("x-api-key", c.apiKey) - req.Header.Set("anthropic-version", anthropicAPIVersion) - req.Header.Set("content-type", "application/json") - req.Header.Set("User-Agent", openmelonUserAgent()) - if handler != nil { - req.Header.Set("accept", "text/event-stream") - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("llm[anthropic]: HTTP: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - respBody, _ := io.ReadAll(resp.Body) - return "", &completeError{provider: "anthropic", status: resp.StatusCode, body: string(respBody)} - } - - if handler == nil { - // Non-streaming path — single JSON response. - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("llm[anthropic]: read response: %w", err) - } - var parsed anthropicResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - return "", fmt.Errorf("llm[anthropic]: parse response: %w (body: %s)", err, string(respBody)) - } - var out bytes.Buffer - for _, block := range parsed.Content { - if block.Type == "text" { - out.WriteString(block.Text) - } - } - return out.String(), nil - } - - // Streaming path — parse SSE events, accumulate text deltas. - var accumulated bytes.Buffer - parseErr := readSSE(ctx, resp.Body, func(ev sseEvent) bool { - // Anthropic uses event names; we only care about content_block_delta. - if ev.event != "content_block_delta" { - return true - } - var delta anthropicStreamDelta - if _, err := jsonDecode(ev.data, &delta); err != nil { - return true // skip malformed events; let the stream finish - } - if delta.Delta.Type == "text_delta" && delta.Delta.Text != "" { - accumulated.WriteString(delta.Delta.Text) - handler(delta.Delta.Text) - } - return true - }) - if parseErr != nil { - return accumulated.String(), fmt.Errorf("llm[anthropic]: stream: %w", parseErr) - } - return accumulated.String(), nil -} - -// anthropicStreamDelta is the per-event payload for content_block_delta. -// Anthropic also emits message_start, content_block_start, message_delta, -// content_block_stop, and message_stop — we ignore all of those for the -// single-turn use case. -type anthropicStreamDelta struct { - Delta struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"delta"` -} diff --git a/internal/llm/chat.go b/internal/llm/chat.go deleted file mode 100644 index f999153..0000000 --- a/internal/llm/chat.go +++ /dev/null @@ -1,133 +0,0 @@ -// chat.go — multi-turn message-list completion with tool calls. -// -// Lives next to client.go but uses a separate ToolCaller interface so -// implementations that don't yet support tools (e.g. our Anthropic -// client today) compile cleanly. Runtime callers do a type assertion -// on Client → ToolCaller and surface a clear error if the underlying -// provider can't do tool use yet. - -package llm - -import ( - "context" - "encoding/json" - "errors" -) - -// ErrToolUseUnsupported is returned by clients that don't yet implement -// Chat. The runtime surfaces this with a clear "switch to openrouter or -// openai" hint instead of a stack trace. -var ErrToolUseUnsupported = errors.New("llm: this provider does not support tool calls yet — use openai or openrouter") - -// Role is a chat message role. Mirrors OpenAI / Anthropic conventions. -type Role string - -const ( - RoleSystem Role = "system" - RoleUser Role = "user" - RoleAssistant Role = "assistant" - RoleTool Role = "tool" -) - -// Message is one entry in a chat history. -type Message struct { - Role Role `json:"role"` - - // Content is the text body. For tool messages, Content carries the - // tool's response (typically JSON-stringified). Empty when an - // assistant message is purely a tool call. - Content string `json:"content,omitempty"` - - // ToolCalls is set on assistant messages that ask the model to call - // one or more tools. Mirrors OpenAI's wire shape. - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - - // ToolCallID is set on tool messages — references the assistant's - // tool-call id this message responds to. - ToolCallID string `json:"tool_call_id,omitempty"` -} - -// Tool describes a callable function the model can choose to invoke. -// -// Parameters is a JSON schema (passed verbatim to the vendor). Keep it -// small — vendors charge for tokens spent in the schema. -type Tool struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` -} - -// ToolCall is the model's request to invoke a tool. -type ToolCall struct { - ID string `json:"id"` - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` -} - -// FinishReason captures why the model stopped emitting tokens this turn. -type FinishReason string - -const ( - FinishStop FinishReason = "stop" - FinishToolCalls FinishReason = "tool_calls" - FinishLength FinishReason = "length" - FinishOther FinishReason = "other" -) - -// ChatRequest is one turn of a multi-turn conversation. -type ChatRequest struct { - Messages []Message - Tools []Tool - Temperature float64 // 0 → vendor default (~0.7) - MaxTokens int // 0 → vendor default - Model string // empty → client default - ReasoningEffort string // "none", "minimal", "low", "medium", "high", "xhigh"; empty → model/provider default -} - -// ChatResponse is the model's reply for one turn. -type ChatResponse struct { - Message Message - FinishReason FinishReason - Usage Usage // zero-value when the vendor didn't report usage -} - -// Usage is the per-turn token count vendors report alongside the -// response. Streaming responses populate Usage on the final chunk -// (via stream_options.include_usage=true on OpenAI-compatible APIs). -// -// Fields are 0 when the vendor didn't report a value. -type Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -// ToolCaller is implemented by clients that support multi-turn chat -// with tool calls. Use a type assertion to detect support: -// -// tc, ok := client.(llm.ToolCaller) -// if !ok { return llm.ErrToolUseUnsupported } -type ToolCaller interface { - Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) -} - -// StreamingToolCaller is implemented by clients that support streaming -// the model's text + tool-call output. The runtime prefers it over -// ToolCaller when available so the user sees per-token output instead -// of a 30-second wait. -// -// Tool-call deltas are reassembled inside the implementation; callers -// receive the fully-resolved ToolCall list in the final ChatResponse, -// not piecemeal. Text deltas, on the other hand, fire as they arrive. -type StreamingToolCaller interface { - StreamChat(ctx context.Context, req ChatRequest, h StreamChatHandler) (*ChatResponse, error) -} - -// StreamChatHandler bundles the per-event callbacks. All fields are -// optional — nil callbacks are skipped. -type StreamChatHandler struct { - // OnText fires for each non-empty text delta. The delta is the new - // chunk only — implementations do not re-send the full accumulated - // text. Concatenate to reconstruct. - OnText func(delta string) -} diff --git a/internal/llm/client.go b/internal/llm/client.go deleted file mode 100644 index c77519f..0000000 --- a/internal/llm/client.go +++ /dev/null @@ -1,115 +0,0 @@ -// Package llm is OpenMelon's pluggable LLM client surface. -// -// Why pluggable: OpenMelon is opinionated about content workflows but neutral -// about model vendors. Users bring their own credentials and pick their own -// vendor; OpenMelon does not embed a default vendor or price-rank them. -// -// Today: Anthropic (Claude) and OpenAI (GPT) — covers the two cases the -// agent loop actually exercises (structured prompt synthesis + image-prompt -// drafting). Google / xAI / OpenRouter slot in as additional Client -// implementations using the same interface. -// -// Implementations use only net/http + encoding/json — no vendor SDKs, -// because the surface OpenMelon uses (single-turn completion, optional -// JSON-only output) is small and SDK churn is a real maintenance tax. -package llm - -import ( - "context" - "errors" - "fmt" -) - -// Client is the cross-vendor completion surface used by the agent loop. -// -// Two completion methods today: -// - Complete: single-turn, returns the full response when done. Used by -// callers that don't care about per-token output (tests, CI, future -// batch workflows). -// - Stream: same single-turn semantics, but invokes a handler for each -// text delta as it arrives, returning the full accumulated response -// at the end. Used by the agent loop in TTY mode so the user sees -// progress instead of staring at a blank terminal for 30 seconds. -// -// Multi-turn / tool use will extend this interface rather than reshape it. -type Client interface { - // Complete sends a single-turn completion request and returns the - // model's text response. For structured-output use, set opts.JSONOnly - // and embed the schema in opts.System or opts.User. - Complete(ctx context.Context, opts CompleteOptions) (string, error) - - // Stream is like Complete, but invokes handler with each text delta - // as it arrives. Returns the full accumulated response. - // - // handler may be nil — in that case Stream behaves like Complete. - // Implementations must be tolerant of slow handlers (do not buffer - // unbounded; let the network back-pressure naturally). - Stream(ctx context.Context, opts CompleteOptions, handler StreamHandler) (string, error) - - // Provider returns the vendor slug (e.g. "anthropic", "openai") for - // telemetry and provenance. - Provider() string - - // Model returns the model id this client will use when CompleteOptions.Model - // is empty. - Model() string -} - -// StreamHandler is invoked for each text delta during streaming. -// Empty deltas are filtered out before the handler is called. -type StreamHandler func(delta string) - -// CompleteOptions describes a single completion request. -// -// Empty values fall back to client defaults; only System or User must be -// non-empty. -type CompleteOptions struct { - // System is the role-setting prompt. May be empty for vendors that - // support only user-role messages, or when the entire instruction - // fits in User. - System string - - // User is the per-request input. Required. - User string - - // Model overrides the client's default model id. Empty → client default. - Model string - - // Temperature is the sampling temperature. Zero → client default - // (typically 0.7 for drafting, 0.2 when JSONOnly is set). - Temperature float64 - - // MaxTokens caps response length. Zero → client default (4096). - MaxTokens int - - // JSONOnly hints the client to enforce JSON-only output where the - // vendor supports it (OpenAI response_format, Anthropic explicit - // instruction). The caller must still validate the returned string - // parses as JSON — this is a hint, not a guarantee. - JSONOnly bool - - // ReasoningEffort passes a thinking-depth hint to providers that - // expose one. Unsupported providers ignore it. - ReasoningEffort string -} - -// ErrNoAPIKey is returned by client constructors when no key is supplied -// AND the env fallback is empty. -var ErrNoAPIKey = errors.New("llm: no API key supplied and no env fallback set") - -// ErrModelRequired is returned when no model id is passed AND no env -// fallback is set. We deliberately do not bake vendor model defaults -// into the source — the menu changes too often. -var ErrModelRequired = errors.New("llm: no model id supplied — pass --llm-model or set the per-provider env var") - -// completeError wraps vendor errors with context. Implementations construct -// this so the agent loop can present a unified failure surface. -type completeError struct { - provider string - status int - body string -} - -func (e *completeError) Error() string { - return fmt.Sprintf("llm[%s]: HTTP %d: %s", e.provider, e.status, e.body) -} diff --git a/internal/llm/factory.go b/internal/llm/factory.go deleted file mode 100644 index fbea2fe..0000000 --- a/internal/llm/factory.go +++ /dev/null @@ -1,59 +0,0 @@ -package llm - -import ( - "fmt" - "os" -) - -// New returns a Client for the requested provider. -// -// provider must be one of: "anthropic", "openai", "openrouter", "auto". -// apiKey, baseURL, defaultModel are all optional — empty values fall back -// to the provider's canonical env vars (e.g. OPENAI_API_KEY + -// OPENAI_BASE_URL) and built-in defaults. -// -// "auto" picks based on which API key the user has set in the -// environment — preferring Anthropic when both are set, since Claude is -// stronger at the structured-output task that the agent loop uses. -// Falls back to openai → openrouter. Returns an error with a helpful -// message if no recognized key is found. -func New(provider, apiKey, baseURL, defaultModel string) (Client, error) { - if provider == "auto" || provider == "" { - provider = autoDetectProvider() - if provider == "" { - return nil, fmt.Errorf( - "llm: --llm=auto could not pick a provider — set one of " + - "ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY, " + - "or pass --llm explicitly") - } - } - switch provider { - case "anthropic": - return NewAnthropic(apiKey, baseURL, defaultModel) - case "openai": - return NewOpenAI(apiKey, baseURL, defaultModel) - case "openrouter": - return NewOpenRouter(apiKey, baseURL, defaultModel) - default: - return nil, fmt.Errorf("llm: unknown provider %q (supported: anthropic, openai, openrouter, auto)", provider) - } -} - -// autoDetectProvider picks a provider from the environment. -// -// Order of preference: anthropic → openai → openrouter. The thinking is -// that Claude is the strongest at structured-JSON output today, but if a -// user has only an OpenAI key, this gracefully degrades rather than -// erroring. -func autoDetectProvider() string { - if os.Getenv("ANTHROPIC_API_KEY") != "" { - return "anthropic" - } - if os.Getenv("OPENAI_API_KEY") != "" { - return "openai" - } - if os.Getenv("OPENROUTER_API_KEY") != "" { - return "openrouter" - } - return "" -} diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go deleted file mode 100644 index 96dd296..0000000 --- a/internal/llm/llm_test.go +++ /dev/null @@ -1,366 +0,0 @@ -package llm - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// Network behavior is exercised against a local httptest server so the -// vendor wire shape is verified without burning real API credits. - -func TestNew_UnknownProvider(t *testing.T) { - if _, err := New("notavendor", "key", "", ""); err == nil { - t.Fatal("expected error for unknown provider") - } -} - -func TestAnthropic_NoKey(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "") - if _, err := NewAnthropic("", "", ""); err != ErrNoAPIKey { - t.Fatalf("expected ErrNoAPIKey, got %v", err) - } -} - -func TestOpenAI_NoKey(t *testing.T) { - t.Setenv("OPENAI_API_KEY", "") - if _, err := NewOpenAI("", "", ""); err != ErrNoAPIKey { - t.Fatalf("expected ErrNoAPIKey, got %v", err) - } -} - -func TestAnthropic_RequestShape(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Errorf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/v1/messages" { - t.Errorf("expected /v1/messages, got %s", r.URL.Path) - } - if r.Header.Get("x-api-key") != "test-key" { - t.Errorf("expected x-api-key=test-key, got %q", r.Header.Get("x-api-key")) - } - if r.Header.Get("anthropic-version") == "" { - t.Errorf("missing anthropic-version header") - } - - var req anthropicRequest - body, _ := io.ReadAll(r.Body) - if err := json.Unmarshal(body, &req); err != nil { - t.Fatalf("unmarshal: %v (body: %s)", err, body) - } - if req.Model != "claude-test" { - t.Errorf("expected model=claude-test, got %q", req.Model) - } - if !strings.Contains(req.System, "test system") { - t.Errorf("expected system to contain 'test system', got %q", req.System) - } - if !strings.Contains(req.System, "Output ONLY a single JSON object") { - t.Errorf("expected JSON-only hint appended to system, got %q", req.System) - } - if len(req.Messages) != 1 || req.Messages[0].Role != "user" || req.Messages[0].Content != "test user" { - t.Errorf("unexpected messages: %+v", req.Messages) - } - if req.Temperature != 0.2 { - t.Errorf("expected temperature=0.2 for JSONOnly, got %v", req.Temperature) - } - - _ = json.NewEncoder(w).Encode(anthropicResponse{ - Content: []anthropicContentBlock{{Type: "text", Text: `{"ok":true}`}}, - }) - })) - defer server.Close() - - c := &AnthropicClient{ - apiKey: "test-key", - baseURL: server.URL, - defaultModel: "claude-test", - httpClient: server.Client(), - } - got, err := c.Complete(context.Background(), CompleteOptions{ - System: "test system", - User: "test user", - JSONOnly: true, - }) - if err != nil { - t.Fatalf("Complete: %v", err) - } - if got != `{"ok":true}` { - t.Errorf("unexpected body: %q", got) - } -} - -func TestAnthropic_ErrorPropagates(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":{"message":"invalid key"}}`)) - })) - defer server.Close() - - c := &AnthropicClient{ - apiKey: "bad-key", - baseURL: server.URL, - defaultModel: "claude-test", - httpClient: server.Client(), - } - _, err := c.Complete(context.Background(), CompleteOptions{User: "hi"}) - if err == nil { - t.Fatal("expected error from 401 response") - } - if !strings.Contains(err.Error(), "401") { - t.Errorf("expected 401 in error, got %v", err) - } -} - -func TestOpenAI_RequestShape_JSONResponseFormat(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/chat/completions" { - t.Errorf("expected /v1/chat/completions, got %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "Bearer test-key" { - t.Errorf("expected Bearer test-key, got %q", r.Header.Get("Authorization")) - } - if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "openmelon-tui/") { - t.Errorf("expected openmelon User-Agent, got %q", ua) - } - - var req openaiRequest - body, _ := io.ReadAll(r.Body) - if err := json.Unmarshal(body, &req); err != nil { - t.Fatalf("unmarshal: %v (body: %s)", err, body) - } - if req.Model != "gpt-test" { - t.Errorf("expected model=gpt-test, got %q", req.Model) - } - if req.ResponseFormat == nil || req.ResponseFormat.Type != "json_object" { - t.Errorf("expected response_format=json_object for openai+JSONOnly, got %+v", req.ResponseFormat) - } - if len(req.Messages) != 2 || req.Messages[0].Role != "system" || req.Messages[1].Role != "user" { - t.Errorf("unexpected messages: %+v", req.Messages) - } - - resp := openaiResponse{Choices: []openaiChoice{{Message: openaiMessage{Role: "assistant", Content: `{"ok":true}`}}}} - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - c := &OpenAIClient{ - apiKey: "test-key", - baseURL: server.URL, - defaultModel: "gpt-test", - provider: "openai", - httpClient: server.Client(), - } - got, err := c.Complete(context.Background(), CompleteOptions{ - System: "you are a test", - User: "hi", - JSONOnly: true, - }) - if err != nil { - t.Fatalf("Complete: %v", err) - } - if got != `{"ok":true}` { - t.Errorf("unexpected body: %q", got) - } -} - -func TestOpenAI_ChatSendsReasoningEffortAndUserAgent(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "openmelon-tui/") { - t.Errorf("expected openmelon User-Agent, got %q", ua) - } - var req openaiChatRequestWire - body, _ := io.ReadAll(r.Body) - if err := json.Unmarshal(body, &req); err != nil { - t.Fatalf("unmarshal: %v (body: %s)", err, body) - } - if req.ReasoningEffort != "xhigh" { - t.Fatalf("reasoning_effort = %q, want xhigh", req.ReasoningEffort) - } - _ = json.NewEncoder(w).Encode(openaiChatResponseWire{ - Choices: []struct { - Message openaiChatMessage `json:"message"` - FinishReason string `json:"finish_reason"` - }{{Message: openaiChatMessage{Role: "assistant", Content: "ok"}, FinishReason: "stop"}}, - }) - })) - defer server.Close() - - c := &OpenAIClient{ - apiKey: "k", - baseURL: server.URL, - defaultModel: "gpt-5.5", - provider: "openai", - httpClient: server.Client(), - } - _, err := c.Chat(context.Background(), ChatRequest{ - Messages: []Message{{Role: RoleUser, Content: "hi"}}, - ReasoningEffort: "xhigh", - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } -} - -func TestOpenRouter_AddsTelemetryHeaders(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("HTTP-Referer") == "" { - t.Errorf("expected HTTP-Referer header for openrouter") - } - if r.Header.Get("X-Title") == "" { - t.Errorf("expected X-Title header for openrouter") - } - _ = json.NewEncoder(w).Encode(openaiResponse{ - Choices: []openaiChoice{{Message: openaiMessage{Content: "ok"}}}, - }) - })) - defer server.Close() - - c := &OpenAIClient{ - apiKey: "k", - baseURL: server.URL, - defaultModel: "any", - provider: "openrouter", - httpClient: server.Client(), - } - if _, err := c.Complete(context.Background(), CompleteOptions{User: "hi"}); err != nil { - t.Fatalf("Complete: %v", err) - } -} - -func TestProviderAndModel(t *testing.T) { - a := &AnthropicClient{defaultModel: "claude-x"} - if a.Provider() != "anthropic" || a.Model() != "claude-x" { - t.Errorf("Anthropic accessors wrong: %s / %s", a.Provider(), a.Model()) - } - o := &OpenAIClient{provider: "openai", defaultModel: "gpt-y"} - if o.Provider() != "openai" || o.Model() != "gpt-y" { - t.Errorf("OpenAI accessors wrong: %s / %s", o.Provider(), o.Model()) - } -} - -// --- new: OpenAI base URL + auto detection --- - -func TestOpenAI_BaseURL_FromExplicitArg(t *testing.T) { - t.Setenv("OPENAI_BASE_URL", "") - c, err := NewOpenAI("test", "https://relay.example.com", "gpt-x") - if err != nil { - t.Fatal(err) - } - if c.BaseURL() != "https://relay.example.com" { - t.Errorf("expected explicit base URL to win, got %q", c.BaseURL()) - } -} - -func TestOpenAI_BaseURL_FromEnvFallback(t *testing.T) { - t.Setenv("OPENAI_BASE_URL", "https://env-relay.example.com") - c, err := NewOpenAI("test", "", "gpt-x") - if err != nil { - t.Fatal(err) - } - if c.BaseURL() != "https://env-relay.example.com" { - t.Errorf("expected env fallback to win, got %q", c.BaseURL()) - } -} - -func TestOpenAI_BaseURL_DefaultsToOfficial(t *testing.T) { - t.Setenv("OPENAI_BASE_URL", "") - c, err := NewOpenAI("test", "", "gpt-x") - if err != nil { - t.Fatal(err) - } - if c.BaseURL() != "https://api.openai.com" { - t.Errorf("expected default base URL, got %q", c.BaseURL()) - } -} - -func TestNew_Auto_PrefersAnthropicWhenBothKeysSet(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "ant-k") - t.Setenv("OPENAI_API_KEY", "openai-k") - c, err := New("auto", "", "", "claude-x") - if err != nil { - t.Fatal(err) - } - if c.Provider() != "anthropic" { - t.Errorf("expected auto → anthropic when both set, got %q", c.Provider()) - } -} - -func TestNew_Auto_FallsBackToOpenAI(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "") - t.Setenv("OPENAI_API_KEY", "openai-k") - t.Setenv("OPENROUTER_API_KEY", "") - c, err := New("auto", "", "", "claude-x") - if err != nil { - t.Fatal(err) - } - if c.Provider() != "openai" { - t.Errorf("expected auto → openai when only OPENAI_API_KEY set, got %q", c.Provider()) - } -} - -func TestNew_Auto_FallsBackToOpenRouter(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "") - t.Setenv("OPENAI_API_KEY", "") - t.Setenv("OPENROUTER_API_KEY", "or-k") - c, err := New("auto", "", "", "claude-x") - if err != nil { - t.Fatal(err) - } - if c.Provider() != "openrouter" { - t.Errorf("expected auto → openrouter, got %q", c.Provider()) - } -} - -func TestNew_Auto_FailsWhenNoKeysSet(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "") - t.Setenv("OPENAI_API_KEY", "") - t.Setenv("OPENROUTER_API_KEY", "") - _, err := New("auto", "", "", "claude-x") - if err == nil { - t.Fatal("expected error for auto with no keys") - } - if !strings.Contains(err.Error(), "ANTHROPIC_API_KEY") { - t.Errorf("expected helpful message listing env vars, got %v", err) - } -} - -func TestNew_EmptyProvider_AlsoMeansAuto(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "") - t.Setenv("OPENAI_API_KEY", "openai-k") - t.Setenv("OPENROUTER_API_KEY", "") - c, err := New("", "", "", "claude-x") - if err != nil { - t.Fatal(err) - } - if c.Provider() != "openai" { - t.Errorf("expected empty provider to behave like 'auto', got %q", c.Provider()) - } -} - -func TestOpenAI_RequestUsesCustomBaseURL(t *testing.T) { - called := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - called = true - _ = json.NewEncoder(w).Encode(openaiResponse{ - Choices: []openaiChoice{{Message: openaiMessage{Content: "ok"}}}, - }) - })) - defer server.Close() - - c, err := NewOpenAI("k", server.URL, "any") - if err != nil { - t.Fatal(err) - } - c.httpClient = server.Client() - if _, err := c.Complete(context.Background(), CompleteOptions{User: "hi"}); err != nil { - t.Fatalf("Complete: %v", err) - } - if !called { - t.Fatal("custom base URL was not hit") - } -} diff --git a/internal/llm/openai.go b/internal/llm/openai.go deleted file mode 100644 index 236f37a..0000000 --- a/internal/llm/openai.go +++ /dev/null @@ -1,286 +0,0 @@ -package llm - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "runtime" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/version" -) - -// OpenAI Chat Completions client. -// -// API docs: https://platform.openai.com/docs/api-reference/chat -// -// Also serves OpenRouter (passes a baseURL override) and any other -// OpenAI-compatible endpoint. - -const ( - openaiDefaultBaseURL = "https://api.openai.com" -) - -// OpenAIClient implements Client against OpenAI's Chat Completions API or -// any compatible endpoint (OpenRouter, vLLM, LM Studio, etc.). -type OpenAIClient struct { - apiKey string - baseURL string - defaultModel string - provider string // "openai" or "openrouter" — used only in Provider() / errors - httpClient *http.Client -} - -// NewOpenAI builds an OpenAIClient against the official OpenAI API -// (or any OpenAI-compatible endpoint). -// -// Env-var fallbacks (only used when the matching argument is ""): -// - apiKey ← OPENAI_API_KEY -// - baseURL ← OPENAI_BASE_URL (host only, no /v1 suffix). Matches the -// official OpenAI SDK convention. Useful for ChatGPT-Plus relays, -// LiteLLM, Helicone, vLLM, LM Studio, or any compatible host. -// -// defaultModel: required — pass an explicit model id. We do not bake in -// vendor model defaults; the menu changes too often to live in source. -func NewOpenAI(apiKey, baseURL, defaultModel string) (*OpenAIClient, error) { - if baseURL == "" { - baseURL = os.Getenv("OPENAI_BASE_URL") - } - if baseURL == "" { - baseURL = openaiDefaultBaseURL - } - return newOpenAILike("openai", apiKey, "OPENAI_API_KEY", baseURL, defaultModel) -} - -// NewOpenRouter builds an OpenAIClient that talks to https://openrouter.ai. -// Uses the same Chat Completions wire shape as OpenAI; only the host and -// the env-var fallback differ. -// -// baseURL override: empty → OPENROUTER_BASE_URL → https://openrouter.ai/api. -// defaultModel is required (e.g. "x-ai/grok-4", "anthropic/claude-sonnet-4-6"). -func NewOpenRouter(apiKey, baseURL, defaultModel string) (*OpenAIClient, error) { - if baseURL == "" { - baseURL = os.Getenv("OPENROUTER_BASE_URL") - } - if baseURL == "" { - baseURL = "https://openrouter.ai/api" - } - return newOpenAILike("openrouter", apiKey, "OPENROUTER_API_KEY", baseURL, defaultModel) -} - -func newOpenAILike(provider, apiKey, envVar, baseURL, defaultModel string) (*OpenAIClient, error) { - if apiKey == "" { - apiKey = os.Getenv(envVar) - } - if apiKey == "" { - return nil, ErrNoAPIKey - } - if defaultModel == "" { - return nil, ErrModelRequired - } - return &OpenAIClient{ - apiKey: apiKey, - baseURL: baseURL, - defaultModel: defaultModel, - provider: provider, - httpClient: &http.Client{Timeout: 120 * time.Second}, - }, nil -} - -// BaseURL returns the resolved base URL for telemetry / debugging. -// Useful when --json output wants to record which endpoint was hit. -func (c *OpenAIClient) BaseURL() string { return c.baseURL } - -func (c *OpenAIClient) Provider() string { return c.provider } -func (c *OpenAIClient) Model() string { return c.defaultModel } - -type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type openaiResponseFormat struct { - Type string `json:"type"` -} - -type openaiRequest struct { - Model string `json:"model"` - Messages []openaiMessage `json:"messages"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens,omitempty"` - ResponseFormat *openaiResponseFormat `json:"response_format,omitempty"` - Stream bool `json:"stream,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` -} - -type openaiChoice struct { - Message openaiMessage `json:"message"` -} - -type openaiResponse struct { - Choices []openaiChoice `json:"choices"` -} - -// Complete sends a chat completion request. -// -// JSONOnly maps to OpenAI's response_format={"type":"json_object"} which -// reliably constrains output to JSON. Anthropic-via-OpenRouter does not -// support that flag, so we fall back to the same explicit instruction -// pattern Anthropic uses. -func (c *OpenAIClient) Complete(ctx context.Context, opts CompleteOptions) (string, error) { - return c.doRequest(ctx, opts, nil) -} - -// Stream sends the same request as Complete but with stream=true, parses -// the SSE response, and invokes handler for each text delta. Returns the -// full accumulated text when the stream ends. -func (c *OpenAIClient) Stream(ctx context.Context, opts CompleteOptions, handler StreamHandler) (string, error) { - return c.doRequest(ctx, opts, handler) -} - -func (c *OpenAIClient) doRequest(ctx context.Context, opts CompleteOptions, handler StreamHandler) (string, error) { - if opts.User == "" { - return "", fmt.Errorf("llm[%s]: User is required", c.provider) - } - - model := opts.Model - if model == "" { - model = c.defaultModel - } - temperature := opts.Temperature - if temperature == 0 { - if opts.JSONOnly { - temperature = 0.2 - } else { - temperature = 0.7 - } - } - - messages := []openaiMessage{} - if opts.System != "" { - sys := opts.System - if opts.JSONOnly && c.provider != "openai" { - sys += "\n\nOutput ONLY a single JSON object. Do not wrap it in markdown fences. Do not add any prose." - } - messages = append(messages, openaiMessage{Role: "system", Content: sys}) - } - messages = append(messages, openaiMessage{Role: "user", Content: opts.User}) - - req := openaiRequest{ - Model: model, - Messages: messages, - Temperature: temperature, - MaxTokens: opts.MaxTokens, - Stream: handler != nil, - } - if effort := openaiReasoningEffort(opts.ReasoningEffort); effort != "" { - req.ReasoningEffort = effort - } - if opts.JSONOnly && c.provider == "openai" { - req.ResponseFormat = &openaiResponseFormat{Type: "json_object"} - } - - body, err := json.Marshal(req) - if err != nil { - return "", fmt.Errorf("llm[%s]: marshal request: %w", c.provider, err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("llm[%s]: build request: %w", c.provider, err) - } - c.setHeaders(httpReq, handler != nil) - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return "", fmt.Errorf("llm[%s]: HTTP: %w", c.provider, err) - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - respBody, _ := io.ReadAll(resp.Body) - return "", &completeError{provider: c.provider, status: resp.StatusCode, body: string(respBody)} - } - - if handler == nil { - // Non-streaming path — single JSON response. - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("llm[%s]: read response: %w", c.provider, err) - } - var parsed openaiResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - return "", fmt.Errorf("llm[%s]: parse response: %w (body: %s)", c.provider, err, string(respBody)) - } - if len(parsed.Choices) == 0 { - return "", fmt.Errorf("llm[%s]: no choices in response (body: %s)", c.provider, string(respBody)) - } - return parsed.Choices[0].Message.Content, nil - } - - // Streaming path — parse SSE events, accumulate text deltas. - var accumulated bytes.Buffer - parseErr := readSSE(ctx, resp.Body, func(ev sseEvent) bool { - var chunk openaiStreamChunk - done, err := jsonDecode(ev.data, &chunk) - if err != nil { - return true // skip malformed; let the stream finish - } - if done { - return false - } - if len(chunk.Choices) == 0 { - return true - } - text := chunk.Choices[0].Delta.Content - if text != "" { - accumulated.WriteString(text) - handler(text) - } - return true - }) - if parseErr != nil { - return accumulated.String(), fmt.Errorf("llm[%s]: stream: %w", c.provider, parseErr) - } - return accumulated.String(), nil -} - -func (c *OpenAIClient) setHeaders(req *http.Request, stream bool) { - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("content-type", "application/json") - req.Header.Set("User-Agent", openmelonUserAgent()) - if stream { - req.Header.Set("accept", "text/event-stream") - } - if c.provider == "openrouter" { - req.Header.Set("HTTP-Referer", "https://github.com/eight-acres-lab/openmelon") - req.Header.Set("X-Title", "openmelon") - } -} - -func openmelonUserAgent() string { - return fmt.Sprintf("openmelon-tui/%s (%s; %s)", version.Version, runtime.GOOS, runtime.GOARCH) -} - -func openaiReasoningEffort(effort string) string { - switch strings.ToLower(strings.TrimSpace(effort)) { - case "none", "minimal", "low", "medium", "high", "xhigh": - return strings.ToLower(strings.TrimSpace(effort)) - default: - return "" - } -} - -// openaiStreamChunk is the per-event payload for a streaming chat completion. -type openaiStreamChunk struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` - } `json:"choices"` -} diff --git a/internal/llm/openai_chat.go b/internal/llm/openai_chat.go deleted file mode 100644 index 7658394..0000000 --- a/internal/llm/openai_chat.go +++ /dev/null @@ -1,210 +0,0 @@ -package llm - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" -) - -// Chat implements ToolCaller against OpenAI's Chat Completions API -// (and OpenRouter, which uses the same wire shape). -// -// We don't stream tool-call deltas — they're awkward to reassemble and -// our runtime only needs the final tool calls per turn. If we want -// streaming "model is thinking" output later, the right move is a -// separate StreamChat that fans both text deltas and finalized tool -// calls into a callback. -func (c *OpenAIClient) Chat(ctx context.Context, in ChatRequest) (*ChatResponse, error) { - if len(in.Messages) == 0 { - return nil, fmt.Errorf("llm[%s]: Chat requires at least one message", c.provider) - } - model := in.Model - if model == "" { - model = c.defaultModel - } - temperature := in.Temperature - if temperature == 0 { - temperature = 0.7 - } - - wireMessages := make([]openaiChatMessage, 0, len(in.Messages)) - for _, m := range in.Messages { - wireMessages = append(wireMessages, toWireMessage(m)) - } - - wireTools := make([]openaiToolWire, 0, len(in.Tools)) - for _, t := range in.Tools { - wireTools = append(wireTools, openaiToolWire{ - Type: "function", - Function: openaiFunctionWire{ - Name: t.Name, - Description: t.Description, - Parameters: t.Parameters, - }, - }) - } - - body, err := json.Marshal(openaiChatRequestWire{ - Model: model, - Messages: wireMessages, - Tools: wireTools, - Temperature: temperature, - MaxTokens: in.MaxTokens, - ReasoningEffort: openaiReasoningEffort(in.ReasoningEffort), - }) - if err != nil { - return nil, fmt.Errorf("llm[%s]: marshal: %w", c.provider, err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("llm[%s]: build request: %w", c.provider, err) - } - c.setHeaders(httpReq, false) - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("llm[%s]: HTTP: %w", c.provider, err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("llm[%s]: read response: %w", c.provider, err) - } - if resp.StatusCode/100 != 2 { - return nil, &completeError{provider: c.provider, status: resp.StatusCode, body: string(respBody)} - } - - var parsed openaiChatResponseWire - if err := json.Unmarshal(respBody, &parsed); err != nil { - return nil, fmt.Errorf("llm[%s]: parse response: %w (body: %s)", c.provider, err, string(respBody)) - } - if len(parsed.Choices) == 0 { - return nil, fmt.Errorf("llm[%s]: no choices in response (body: %s)", c.provider, string(respBody)) - } - ch := parsed.Choices[0] - - out := &ChatResponse{ - Message: fromWireMessage(ch.Message), - FinishReason: mapFinishReason(ch.FinishReason), - } - if parsed.Usage != nil { - out.Usage = Usage{ - PromptTokens: parsed.Usage.PromptTokens, - CompletionTokens: parsed.Usage.CompletionTokens, - TotalTokens: parsed.Usage.TotalTokens, - } - } - return out, nil -} - -// --- wire types --- - -type openaiChatRequestWire struct { - Model string `json:"model"` - Messages []openaiChatMessage `json:"messages"` - Tools []openaiToolWire `json:"tools,omitempty"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` -} - -type openaiChatMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCalls []openaiToolCallWire `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -type openaiToolWire struct { - Type string `json:"type"` - Function openaiFunctionWire `json:"function"` -} - -type openaiFunctionWire struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` -} - -type openaiToolCallWire struct { - ID string `json:"id"` - Type string `json:"type"` - Function openaiToolCallFunction `json:"function"` -} - -type openaiToolCallFunction struct { - Name string `json:"name"` - // Arguments is wire-encoded as a JSON string (yes, a JSON string - // containing JSON). We round-trip it through json.RawMessage on our - // side so callers can re-parse without double-decoding. - Arguments string `json:"arguments"` -} - -type openaiChatResponseWire struct { - Choices []struct { - Message openaiChatMessage `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *openaiUsageWire `json:"usage"` -} - -type openaiUsageWire struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -// --- mapping helpers --- - -func toWireMessage(m Message) openaiChatMessage { - out := openaiChatMessage{ - Role: string(m.Role), - Content: m.Content, - ToolCallID: m.ToolCallID, - } - for _, tc := range m.ToolCalls { - out.ToolCalls = append(out.ToolCalls, openaiToolCallWire{ - ID: tc.ID, - Type: "function", - Function: openaiToolCallFunction{ - Name: tc.Name, - Arguments: string(tc.Arguments), - }, - }) - } - return out -} - -func fromWireMessage(w openaiChatMessage) Message { - out := Message{ - Role: Role(w.Role), - Content: w.Content, - ToolCallID: w.ToolCallID, - } - for _, tc := range w.ToolCalls { - out.ToolCalls = append(out.ToolCalls, ToolCall{ - ID: tc.ID, - Name: tc.Function.Name, - Arguments: json.RawMessage(tc.Function.Arguments), - }) - } - return out -} - -func mapFinishReason(s string) FinishReason { - switch s { - case "stop": - return FinishStop - case "tool_calls": - return FinishToolCalls - case "length": - return FinishLength - default: - return FinishOther - } -} diff --git a/internal/llm/openai_chat_stream.go b/internal/llm/openai_chat_stream.go deleted file mode 100644 index 7b7eb18..0000000 --- a/internal/llm/openai_chat_stream.go +++ /dev/null @@ -1,220 +0,0 @@ -package llm - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" -) - -// StreamChat implements StreamingToolCaller against OpenAI Chat -// Completions (and OpenRouter, same wire shape). -// -// Streams text deltas via h.OnText as they arrive. Tool-call deltas -// accumulate silently — vendors split tool_call.function.arguments -// across many chunks ("{\"que" + "ry\":\"vendor\"}"), so streaming them -// raw isn't useful. The final ChatResponse contains fully reassembled -// tool calls keyed by their tool_call_index. -// -// Cancel via ctx — readSSE checks the context between events. -func (c *OpenAIClient) StreamChat(ctx context.Context, in ChatRequest, h StreamChatHandler) (*ChatResponse, error) { - if len(in.Messages) == 0 { - return nil, fmt.Errorf("llm[%s]: StreamChat requires at least one message", c.provider) - } - model := in.Model - if model == "" { - model = c.defaultModel - } - temperature := in.Temperature - if temperature == 0 { - temperature = 0.7 - } - - wireMessages := make([]openaiChatMessage, 0, len(in.Messages)) - for _, m := range in.Messages { - wireMessages = append(wireMessages, toWireMessage(m)) - } - wireTools := make([]openaiToolWire, 0, len(in.Tools)) - for _, t := range in.Tools { - wireTools = append(wireTools, openaiToolWire{ - Type: "function", - Function: openaiFunctionWire{ - Name: t.Name, - Description: t.Description, - Parameters: t.Parameters, - }, - }) - } - - body, err := json.Marshal(openaiChatStreamRequestWire{ - Model: model, - Messages: wireMessages, - Tools: wireTools, - Temperature: temperature, - MaxTokens: in.MaxTokens, - Stream: true, - StreamOptions: &openaiStreamOptionsWire{IncludeUsage: true}, - ReasoningEffort: openaiReasoningEffort(in.ReasoningEffort), - }) - if err != nil { - return nil, fmt.Errorf("llm[%s]: marshal: %w", c.provider, err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("llm[%s]: build request: %w", c.provider, err) - } - c.setHeaders(httpReq, true) - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("llm[%s]: HTTP: %w", c.provider, err) - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - respBody, _ := io.ReadAll(resp.Body) - return nil, &completeError{provider: c.provider, status: resp.StatusCode, body: string(respBody)} - } - - // Per-stream accumulator: text + tool-call args (indexed by the - // tool_call.index field, NOT by ToolCall.ID — id only appears on - // the first delta for each tool call). - var textBuf bytes.Buffer - toolByIdx := map[int]*ToolCall{} - toolArgsByIdx := map[int]*bytes.Buffer{} - var finishReason FinishReason = FinishOther - var usage Usage - - parseErr := readSSE(ctx, resp.Body, func(ev sseEvent) bool { - var chunk openaiChatStreamChunk - done, err := jsonDecode(ev.data, &chunk) - if err != nil { - return true // skip malformed; let the stream finish - } - if done { - return false - } - // Final chunk often has empty Choices but populated Usage. - if chunk.Usage != nil { - usage = Usage{ - PromptTokens: chunk.Usage.PromptTokens, - CompletionTokens: chunk.Usage.CompletionTokens, - TotalTokens: chunk.Usage.TotalTokens, - } - } - if len(chunk.Choices) == 0 { - return true - } - ch := chunk.Choices[0] - if ch.Delta.Content != "" { - textBuf.WriteString(ch.Delta.Content) - if h.OnText != nil { - h.OnText(ch.Delta.Content) - } - } - for _, tcd := range ch.Delta.ToolCalls { - tc, ok := toolByIdx[tcd.Index] - if !ok { - tc = &ToolCall{} - toolByIdx[tcd.Index] = tc - toolArgsByIdx[tcd.Index] = &bytes.Buffer{} - } - if tcd.ID != "" { - tc.ID = tcd.ID - } - if tcd.Function.Name != "" { - tc.Name = tcd.Function.Name - } - if tcd.Function.Arguments != "" { - toolArgsByIdx[tcd.Index].WriteString(tcd.Function.Arguments) - } - } - if ch.FinishReason != "" { - finishReason = mapFinishReason(ch.FinishReason) - } - return true - }) - if parseErr != nil { - return nil, fmt.Errorf("llm[%s]: stream: %w", c.provider, parseErr) - } - - // Materialize tool calls in index order so a multi-call turn stays - // stable across runs. - maxIdx := -1 - for idx := range toolByIdx { - if idx > maxIdx { - maxIdx = idx - } - } - var calls []ToolCall - for i := 0; i <= maxIdx; i++ { - tc, ok := toolByIdx[i] - if !ok { - continue - } - args := toolArgsByIdx[i].Bytes() - if len(args) == 0 { - args = []byte("{}") - } - calls = append(calls, ToolCall{ - ID: tc.ID, - Name: tc.Name, - Arguments: json.RawMessage(args), - }) - } - - return &ChatResponse{ - Message: Message{ - Role: RoleAssistant, - Content: textBuf.String(), - ToolCalls: calls, - }, - FinishReason: finishReason, - Usage: usage, - }, nil -} - -// --- streaming wire shapes --- - -type openaiChatStreamRequestWire struct { - Model string `json:"model"` - Messages []openaiChatMessage `json:"messages"` - Tools []openaiToolWire `json:"tools,omitempty"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream"` - StreamOptions *openaiStreamOptionsWire `json:"stream_options,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` -} - -// openaiStreamOptionsWire enables usage in the final stream chunk. -// Without this, OpenAI-compatible APIs (incl. OpenRouter) omit usage -// during streaming; we'd have to call /usage separately or estimate. -type openaiStreamOptionsWire struct { - IncludeUsage bool `json:"include_usage"` -} - -type openaiChatStreamChunk struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - ToolCalls []openaiToolCallDeltaWire `json:"tool_calls"` - } `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - // Populated only on the final chunk when stream_options.include_usage - // is true. May be nil on every other chunk. - Usage *openaiUsageWire `json:"usage"` -} - -type openaiToolCallDeltaWire struct { - Index int `json:"index"` - ID string `json:"id"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` -} diff --git a/internal/llm/openai_chat_stream_test.go b/internal/llm/openai_chat_stream_test.go deleted file mode 100644 index b1a6f63..0000000 --- a/internal/llm/openai_chat_stream_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package llm - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// streamingServer fakes an OpenAI-compatible streaming endpoint by -// writing each event in `events` as `data: \n\n`. Terminates with -// a [DONE] marker. -func streamingServer(t *testing.T, events []map[string]any) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "openmelon-tui/") { - t.Errorf("expected openmelon User-Agent, got %q", ua) - } - w.Header().Set("Content-Type", "text/event-stream") - flusher, _ := w.(http.Flusher) - for _, ev := range events { - b, _ := json.Marshal(ev) - fmt.Fprintf(w, "data: %s\n\n", b) - if flusher != nil { - flusher.Flush() - } - } - fmt.Fprintf(w, "data: [DONE]\n\n") - })) -} - -func TestStreamChat_AccumulatesTextDeltasAndCallsHandler(t *testing.T) { - server := streamingServer(t, []map[string]any{ - {"choices": []any{map[string]any{"delta": map[string]any{"role": "assistant"}}}}, - {"choices": []any{map[string]any{"delta": map[string]any{"content": "Hel"}}}}, - {"choices": []any{map[string]any{"delta": map[string]any{"content": "lo "}}}}, - {"choices": []any{map[string]any{"delta": map[string]any{"content": "world"}}}}, - {"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}}, - }) - defer server.Close() - - c := &OpenAIClient{apiKey: "k", baseURL: server.URL, defaultModel: "gpt", provider: "openai", httpClient: server.Client()} - var seen []string - resp, err := c.StreamChat(context.Background(), ChatRequest{ - Messages: []Message{{Role: RoleUser, Content: "hi"}}, - }, StreamChatHandler{ - OnText: func(d string) { seen = append(seen, d) }, - }) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - if got := strings.Join(seen, ""); got != "Hello world" { - t.Errorf("text deltas concatenated: %q", got) - } - if resp.Message.Content != "Hello world" { - t.Errorf("final content: %q", resp.Message.Content) - } - if resp.FinishReason != FinishStop { - t.Errorf("finish reason: %v", resp.FinishReason) - } -} - -func TestStreamChat_ReassemblesToolCallDeltas(t *testing.T) { - server := streamingServer(t, []map[string]any{ - // First chunk for the tool call: id + name, no arguments yet. - {"choices": []any{map[string]any{"delta": map[string]any{ - "tool_calls": []any{map[string]any{ - "index": 0, - "id": "call_1", - "function": map[string]any{ - "name": "list_characters", - "arguments": "", - }, - }}, - }}}}, - // Arguments stream across multiple chunks. - {"choices": []any{map[string]any{"delta": map[string]any{ - "tool_calls": []any{map[string]any{ - "index": 0, - "function": map[string]any{ - "arguments": "{\"que", - }, - }}, - }}}}, - {"choices": []any{map[string]any{"delta": map[string]any{ - "tool_calls": []any{map[string]any{ - "index": 0, - "function": map[string]any{ - "arguments": "ry\":\"vendor\"}", - }, - }}, - }}}}, - {"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "tool_calls"}}}, - }) - defer server.Close() - - c := &OpenAIClient{apiKey: "k", baseURL: server.URL, defaultModel: "gpt", provider: "openai", httpClient: server.Client()} - resp, err := c.StreamChat(context.Background(), ChatRequest{ - Messages: []Message{{Role: RoleUser, Content: "find vendors"}}, - }, StreamChatHandler{}) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - if len(resp.Message.ToolCalls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(resp.Message.ToolCalls)) - } - tc := resp.Message.ToolCalls[0] - if tc.ID != "call_1" || tc.Name != "list_characters" { - t.Errorf("tool call mismatch: %+v", tc) - } - if string(tc.Arguments) != `{"query":"vendor"}` { - t.Errorf("arguments: %q", string(tc.Arguments)) - } - if resp.FinishReason != FinishToolCalls { - t.Errorf("finish reason: %v", resp.FinishReason) - } -} - -func TestStreamChat_TwoToolCallsPreserveOrder(t *testing.T) { - server := streamingServer(t, []map[string]any{ - // Two tool calls interleaved by index. - {"choices": []any{map[string]any{"delta": map[string]any{ - "tool_calls": []any{ - map[string]any{"index": 0, "id": "a", "function": map[string]any{"name": "first", "arguments": "{}"}}, - map[string]any{"index": 1, "id": "b", "function": map[string]any{"name": "second", "arguments": "{}"}}, - }, - }}}}, - {"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "tool_calls"}}}, - }) - defer server.Close() - - c := &OpenAIClient{apiKey: "k", baseURL: server.URL, defaultModel: "gpt", provider: "openai", httpClient: server.Client()} - resp, err := c.StreamChat(context.Background(), ChatRequest{Messages: []Message{{Role: RoleUser, Content: "go"}}}, StreamChatHandler{}) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - if len(resp.Message.ToolCalls) != 2 { - t.Fatalf("expected 2 tool calls, got %d", len(resp.Message.ToolCalls)) - } - if resp.Message.ToolCalls[0].Name != "first" || resp.Message.ToolCalls[1].Name != "second" { - t.Errorf("tool call order: %+v", resp.Message.ToolCalls) - } -} - -func TestStreamChat_HTTPErrorIsSurfaced(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"bad"}`)) - })) - defer server.Close() - - c := &OpenAIClient{apiKey: "k", baseURL: server.URL, defaultModel: "gpt", provider: "openai", httpClient: server.Client()} - _, err := c.StreamChat(context.Background(), ChatRequest{Messages: []Message{{Role: RoleUser, Content: "x"}}}, StreamChatHandler{}) - if err == nil || !strings.Contains(err.Error(), "400") { - t.Errorf("expected 400 error, got %v", err) - } -} diff --git a/internal/llm/sse.go b/internal/llm/sse.go deleted file mode 100644 index 8048c22..0000000 --- a/internal/llm/sse.go +++ /dev/null @@ -1,98 +0,0 @@ -package llm - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "strings" -) - -// SSE parsing helpers shared by Anthropic and OpenAI streaming clients. -// -// Both vendors emit Server-Sent Events. The framing rules are the same: -// each event is a sequence of "field: value" lines terminated by a blank -// line. The "data: " field carries a JSON payload (or the literal -// "[DONE]" sentinel for OpenAI). Anthropic also emits an "event: " field -// naming the event type, which we use to ignore non-content events. -// -// We hand-parse rather than pull in a dep: the format is small and we -// already deliberately avoid vendor SDKs (see internal/llm/client.go). - -// sseEvent is one parsed SSE event. Only the fields we care about. -type sseEvent struct { - event string // OpenAI omits this; Anthropic uses content_block_delta etc. - data string // raw JSON or "[DONE]" -} - -// readSSE parses an SSE response stream and invokes onEvent for each -// complete event. Returns when the stream ends, the context is -// cancelled, or onEvent returns false. -func readSSE(ctx context.Context, body io.Reader, onEvent func(sseEvent) bool) error { - scanner := bufio.NewScanner(body) - // SSE events can be large; default 64 KiB buffer is too small for - // some Anthropic message_start payloads. - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - var ev sseEvent - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - line := scanner.Text() - if line == "" { - // End of event. Dispatch and reset. - if ev.data != "" { - if !onEvent(ev) { - return nil - } - } - ev = sseEvent{} - continue - } - if strings.HasPrefix(line, ":") { - // SSE comment line, e.g. ": ping". Ignore. - continue - } - if strings.HasPrefix(line, "event:") { - ev.event = strings.TrimSpace(line[len("event:"):]) - continue - } - if strings.HasPrefix(line, "data:") { - payload := strings.TrimSpace(line[len("data:"):]) - if ev.data == "" { - ev.data = payload - } else { - // Multi-line data fields concatenate with \n per spec. - ev.data = ev.data + "\n" + payload - } - continue - } - // Ignore other field types (id:, retry:). - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("sse: read: %w", err) - } - // Dispatch any unterminated event at EOF. - if ev.data != "" { - _ = onEvent(ev) - } - return nil -} - -// jsonDecode decodes a JSON payload into v, ignoring the [DONE] sentinel. -// Returns (true, nil) on [DONE], (false, nil) on success, (false, err) on -// parse failure. -func jsonDecode(payload string, v any) (done bool, err error) { - if payload == "[DONE]" { - return true, nil - } - if err := json.Unmarshal([]byte(payload), v); err != nil { - return false, fmt.Errorf("sse: parse data: %w (payload: %s)", err, payload) - } - return false, nil -} diff --git a/internal/llm/sse_test.go b/internal/llm/sse_test.go deleted file mode 100644 index ddd4000..0000000 --- a/internal/llm/sse_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package llm - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestAnthropic_Stream_AccumulatesTextDeltas(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("content-type", "text/event-stream") - // Two text deltas → "Hello world"; intersperse a non-text event - // so we exercise the event-name filter. - fmt.Fprint(w, "event: message_start\n") - fmt.Fprint(w, "data: {\"type\":\"message_start\"}\n\n") - fmt.Fprint(w, "event: content_block_delta\n") - fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n") - fmt.Fprint(w, "event: content_block_delta\n") - fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n") - fmt.Fprint(w, "event: message_stop\n") - fmt.Fprint(w, "data: {\"type\":\"message_stop\"}\n\n") - })) - defer server.Close() - - c := &AnthropicClient{ - apiKey: "test", baseURL: server.URL, defaultModel: "claude-test", - httpClient: server.Client(), - } - - var deltas []string - got, err := c.Stream(context.Background(), CompleteOptions{User: "hi"}, func(d string) { - deltas = append(deltas, d) - }) - if err != nil { - t.Fatalf("Stream: %v", err) - } - if got != "Hello world" { - t.Errorf("accumulated = %q, want %q", got, "Hello world") - } - if len(deltas) != 2 || deltas[0] != "Hello" || deltas[1] != " world" { - t.Errorf("deltas = %#v", deltas) - } -} - -func TestAnthropic_Stream_RequestSetsStreamFlag(t *testing.T) { - var sawStream bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "openmelon-tui/") { - t.Errorf("expected openmelon User-Agent, got %q", ua) - } - var req anthropicRequest - _ = json.NewDecoder(r.Body).Decode(&req) - sawStream = req.Stream - w.Header().Set("content-type", "text/event-stream") - fmt.Fprint(w, "event: message_stop\ndata: {}\n\n") - })) - defer server.Close() - - c := &AnthropicClient{apiKey: "k", baseURL: server.URL, defaultModel: "x", httpClient: server.Client()} - _, _ = c.Stream(context.Background(), CompleteOptions{User: "hi"}, func(string) {}) - if !sawStream { - t.Error("expected stream:true in request body") - } -} - -func TestOpenAI_Stream_AccumulatesContentDeltas(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("content-type", "text/event-stream") - fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n") - fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n") - fmt.Fprint(w, "data: [DONE]\n\n") - })) - defer server.Close() - - c := &OpenAIClient{apiKey: "k", baseURL: server.URL, defaultModel: "gpt-x", provider: "openai", httpClient: server.Client()} - - var deltas []string - got, err := c.Stream(context.Background(), CompleteOptions{User: "hi"}, func(d string) { - deltas = append(deltas, d) - }) - if err != nil { - t.Fatalf("Stream: %v", err) - } - if got != "Hello world" { - t.Errorf("accumulated = %q", got) - } - if len(deltas) != 2 { - t.Errorf("deltas = %#v", deltas) - } -} - -func TestOpenAI_Stream_HandlesDONESentinel(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("content-type", "text/event-stream") - fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n") - fmt.Fprint(w, "data: [DONE]\n\n") - // Anything after [DONE] should not be processed (we return false - // from the SSE callback). - fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"AFTER_DONE\"}}]}\n\n") - })) - defer server.Close() - - c := &OpenAIClient{apiKey: "k", baseURL: server.URL, defaultModel: "gpt-x", provider: "openai", httpClient: server.Client()} - got, err := c.Stream(context.Background(), CompleteOptions{User: "hi"}, func(string) {}) - if err != nil { - t.Fatalf("Stream: %v", err) - } - if strings.Contains(got, "AFTER_DONE") { - t.Errorf("text after [DONE] should be ignored, got %q", got) - } -} - -func TestStream_PropagatesNon2xx(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":"bad"}`)) - })) - defer server.Close() - - c := &AnthropicClient{apiKey: "k", baseURL: server.URL, defaultModel: "x", httpClient: server.Client()} - _, err := c.Stream(context.Background(), CompleteOptions{User: "hi"}, func(string) {}) - if err == nil || !strings.Contains(err.Error(), "401") { - t.Errorf("expected 401, got %v", err) - } -} diff --git a/internal/onboard/auth.go b/internal/onboard/auth.go deleted file mode 100644 index 5703904..0000000 --- a/internal/onboard/auth.go +++ /dev/null @@ -1,378 +0,0 @@ -package onboard - -// auth.go — provider + API key + model wizard. -// -// Single bubbletea program with three internal states: -// -// stateProvider — pick openrouter / openai / anthropic -// stateKey — masked API-key input (or "use detected env var") -// stateLLMModel — confirm / edit the LLM model id -// stateImageModel — confirm / edit / skip image model id -// -// On finish: writes credentials.json + config.json defaults. - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -// modelPreset is one curated model option offered in the model-picker -// step. Subtitles are short ("recommended", "cheap, fast", etc.) — the -// list is meant to fit in one screen. -type modelPreset struct { - id string - subtitle string -} - -// providerOption is one row in the provider menu. -type providerOption struct { - slug string // "openrouter" / "openai" / "anthropic" - title string - subtitle string - envVar string // OPENROUTER_API_KEY etc. - defaultLLMModel string - defaultImgModel string - imgProvider string // empty = no image support - - // llmPresets / imagePresets are the curated model options shown in - // the model-picker steps. The orchestrator appends a "Custom" item - // at the end so users can type their own id. - llmPresets []modelPreset - imagePresets []modelPreset -} - -var providerOptions = []providerOption{ - { - slug: "openrouter", title: "Use OpenRouter", - subtitle: "Recommended. Routes to GPT, Claude, Gemini, Grok, etc. Has both LLM and image models.", - envVar: "OPENROUTER_API_KEY", - defaultLLMModel: "openai/gpt-5.5", - defaultImgModel: "google/gemini-2.5-flash-image", - imgProvider: "openrouter", - // Top 10 text models on OR (May 2026), curated from - // openrouter.ai/rankings + popularity. Custom row appended by - // the selector UI. - llmPresets: []modelPreset{ - {id: "openai/gpt-5.5", subtitle: "Recommended. OpenAI's latest unified agentic flagship (Apr 2026)."}, - {id: "anthropic/claude-opus-4.7", subtitle: "Anthropic's max model. Best for long-running agents."}, - {id: "anthropic/claude-sonnet-4.6", subtitle: "Balanced reasoning + speed. Highest non-OpenAI usage on OR."}, - {id: "google/gemini-3-flash-preview", subtitle: "Fast thinking, near-Pro reasoning, agentic workflows."}, - {id: "google/gemini-3-pro-preview", subtitle: "Google flagship. Best multimodal reasoning."}, - {id: "x-ai/grok-4.1-fast", subtitle: "Best agentic tool calling on OR. 2M context."}, - {id: "openai/gpt-5-mini", subtitle: "Cheap + fast OpenAI. Good for iteration."}, - {id: "anthropic/claude-haiku-4.5", subtitle: "Cheap + fast Claude."}, - {id: "google/gemini-2.5-pro", subtitle: "Long context fallback. Solid all-rounder."}, - {id: "x-ai/grok-4", subtitle: "Premium creative outputs."}, - }, - // Top 5 image models on OR (May 2026), curated from - // openrouter.ai/collections/image-models. Mix of vendors. - imagePresets: []modelPreset{ - {id: "google/gemini-2.5-flash-image", subtitle: "Recommended. Cheapest good model on OR (\"Nano Banana\")."}, - {id: "google/gemini-3-pro-image-preview", subtitle: "Premium quality, 2K/4K (\"Nano Banana Pro\")."}, - {id: "openai/gpt-5-image-mini", subtitle: "Balanced cost/quality. Strong text rendering."}, - {id: "openai/gpt-5.4-image-2", subtitle: "Premium OpenAI. GPT-5.4 reasoning + image."}, - {id: "black-forest-labs/flux.2-pro", subtitle: "Alt vendor. Strong prompt adherence, sharp textures, 4MP."}, - }, - }, - { - slug: "openai", title: "Use OpenAI", - subtitle: "Direct OpenAI API. Has both chat and image.", - envVar: "OPENAI_API_KEY", - defaultLLMModel: "gpt-5.5", - defaultImgModel: "gpt-5.4-image-2", - imgProvider: "openai", - llmPresets: []modelPreset{ - {id: "gpt-5.5", subtitle: "Recommended. Newest unified agentic flagship (Apr 2026)."}, - {id: "gpt-5", subtitle: "Previous flagship. Mature, well-tested."}, - {id: "gpt-5-mini", subtitle: "Cheaper / faster."}, - {id: "gpt-4o", subtitle: "Legacy multimodal."}, - }, - imagePresets: []modelPreset{ - {id: "gpt-5.4-image-2", subtitle: "Recommended. GPT-5.4 reasoning + image."}, - {id: "gpt-5-image", subtitle: "Premium quality."}, - {id: "gpt-5-image-mini", subtitle: "Balanced cost/quality."}, - {id: "gpt-image-1", subtitle: "Standard."}, - {id: "dall-e-3", subtitle: "Legacy."}, - }, - }, - { - slug: "anthropic", title: "Use Anthropic", - subtitle: "Claude only. No image generation — pair with OpenAI/OpenRouter for images.", - envVar: "ANTHROPIC_API_KEY", - defaultLLMModel: "claude-sonnet-4.6", - llmPresets: []modelPreset{ - {id: "claude-opus-4.7", subtitle: "Recommended. Most capable. Best for complex / long-running agents."}, - {id: "claude-sonnet-4.6", subtitle: "Balanced reasoning + speed."}, - {id: "claude-haiku-4.5", subtitle: "Cheap, fast."}, - }, - }, -} - -// EnsureAuth runs the provider/key/model wizard if no auth is -// configured yet. Returns the chosen provider+key so the caller can -// pass them straight into llm.New. -// -// Skips silently when credentials.json already has at least one key. -func EnsureAuth() (configured bool, err error) { - creds, err := userconfig.LoadCredentials() - if err != nil { - return false, err - } - if len(creds.APIKeys) > 0 { - return true, nil - } - - // Step 1: pick provider. - header := headerStyle.Render("Welcome to openmelon") + "\n\n" + - bodyStyle.Render("openmelon needs an API key to talk to a model.\nPick one provider — you can change later with `openmelon setup`.") - items := make([]ListItem, len(providerOptions)) - for i, p := range providerOptions { - items[i] = ListItem{Title: p.title, Subtitle: p.subtitle} - } - res, err := RunList(listOpts{ - Title: header, - Items: items, - Help: "↑/↓ to choose · 1/2/3 shortcut · enter to continue · ctrl+c to quit", - }) - if err != nil { - return false, err - } - if res.Cancelled { - return false, nil - } - chosen := providerOptions[res.Index] - - // Step 2: API key input. Detect env var first. - envKey := os.Getenv(chosen.envVar) - apiKey := envKey - if envKey == "" { - apiKey, err = runKeyInput(chosen) - if err != nil { - return false, err - } - if apiKey == "" { - return false, nil - } - } else { - // Confirm reuse. - ok, err := runReuseEnvPrompt(chosen, envKey) - if err != nil { - return false, err - } - if !ok { - apiKey, err = runKeyInput(chosen) - if err != nil { - return false, err - } - if apiKey == "" { - return false, nil - } - } - } - - // Step 3: confirm LLM model. - llmModel, err := runModelInput("LLM model", chosen.defaultLLMModel) - if err != nil { - return false, err - } - if llmModel == "" { - llmModel = chosen.defaultLLMModel - } - - // Step 4: image model (or skip). - var imageProvider, imageModel string - if chosen.imgProvider != "" { - imageModel, err = runModelInput("Image model (leave blank to skip image generation)", chosen.defaultImgModel) - if err != nil { - return false, err - } - if imageModel != "" { - imageProvider = chosen.imgProvider - } - } - - // Persist. - if err := userconfig.SetAPIKey(chosen.slug, apiKey); err != nil { - return false, fmt.Errorf("auth: save key: %w", err) - } - cfg, err := userconfig.LoadConfig() - if err != nil { - return false, err - } - cfg.Defaults.LLMProvider = chosen.slug - cfg.Defaults.LLMModel = llmModel - cfg.Defaults.ImageProvider = imageProvider - cfg.Defaults.ImageModel = imageModel - if err := userconfig.SaveConfig(cfg); err != nil { - return false, fmt.Errorf("auth: save config: %w", err) - } - return true, nil -} - -// --- key input --- - -type keyInputModel struct { - provider providerOption - input textinput.Model - done bool - cancel bool -} - -func newKeyInputModel(p providerOption) *keyInputModel { - ti := textinput.New() - ti.Placeholder = p.envVar - ti.EchoMode = textinput.EchoPassword - ti.EchoCharacter = '•' - ti.CharLimit = 500 - ti.Width = 60 - ti.Focus() - return &keyInputModel{provider: p, input: ti} -} - -func (m *keyInputModel) Init() tea.Cmd { return textinput.Blink } - -func (m *keyInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if k, ok := msg.(tea.KeyMsg); ok { - switch { - case key.Matches(k, key.NewBinding(key.WithKeys("ctrl+c", "esc"))): - m.cancel = true - return m, finishCancelled() - case key.Matches(k, key.NewBinding(key.WithKeys("enter"))): - val := strings.TrimSpace(m.input.Value()) - if val != "" { - m.done = true - return m, finishWith(val) - } - } - } - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd -} - -func (m *keyInputModel) View() string { - var b strings.Builder - b.WriteString(headerStyle.Render(fmt.Sprintf("Paste your %s API key", m.provider.title[len("Use "):]))) - b.WriteString("\n\n") - b.WriteString(bodyStyle.Render("It will be stored at ~/.openmelon/credentials.json (mode 0600).")) - b.WriteString("\n\n") - b.WriteString(m.input.View()) - b.WriteString("\n\n") - b.WriteString(helpStyle.Render("enter to continue · esc to cancel")) - b.WriteString("\n") - return b.String() -} - -func runKeyInput(p providerOption) (string, error) { - m := newKeyInputModel(p) - runner := &singleShotRunner{inner: m} - if _, err := tea.NewProgram(runner, tea.WithAltScreen()).Run(); err != nil { - return "", err - } - if runner.cancelled { - return "", nil - } - if s, ok := runner.payload.(string); ok { - return s, nil - } - return strings.TrimSpace(m.input.Value()), nil -} - -// --- reuse-env prompt --- - -func runReuseEnvPrompt(p providerOption, envKey string) (bool, error) { - masked := envKey - if len(masked) > 8 { - masked = masked[:4] + "…" + masked[len(masked)-4:] - } - res, err := RunList(listOpts{ - Title: headerStyle.Render(fmt.Sprintf("Detected %s in your environment", p.envVar)) + "\n\n" + - bodyStyle.Render(fmt.Sprintf("Use %s as the %s key?", masked, p.title[len("Use "):])), - Items: []ListItem{ - {Title: "Yes, use it"}, - {Title: "No, paste a different one"}, - }, - Help: "enter to continue · esc to cancel", - }) - if err != nil { - return false, err - } - if res.Cancelled { - return false, nil - } - return res.Index == 0, nil -} - -// --- model id input --- - -type modelInputModel struct { - prompt string - defVal string - input textinput.Model - done bool - cancel bool - cleared bool // true if user explicitly cleared (returned empty) -} - -func newModelInputModel(prompt, def string) *modelInputModel { - ti := textinput.New() - ti.Placeholder = def - ti.SetValue(def) - ti.CharLimit = 200 - ti.Width = 60 - ti.Focus() - return &modelInputModel{prompt: prompt, defVal: def, input: ti} -} - -func (m *modelInputModel) Init() tea.Cmd { return textinput.Blink } - -func (m *modelInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if k, ok := msg.(tea.KeyMsg); ok { - switch { - case key.Matches(k, key.NewBinding(key.WithKeys("ctrl+c", "esc"))): - m.cancel = true - return m, finishCancelled() - case key.Matches(k, key.NewBinding(key.WithKeys("enter"))): - m.done = true - return m, finishWith(strings.TrimSpace(m.input.Value())) - } - } - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd -} - -func (m *modelInputModel) View() string { - var b strings.Builder - b.WriteString(headerStyle.Render(m.prompt)) - b.WriteString("\n\n") - b.WriteString(bodyStyle.Render(fmt.Sprintf("Default: %s. Enter to accept, or edit the line.", m.defVal))) - b.WriteString("\n\n") - b.WriteString(m.input.View()) - b.WriteString("\n\n") - b.WriteString(helpStyle.Render("enter to continue · esc to cancel")) - b.WriteString("\n") - return b.String() -} - -func runModelInput(prompt, def string) (string, error) { - m := newModelInputModel(prompt, def) - runner := &singleShotRunner{inner: m} - if _, err := tea.NewProgram(runner, tea.WithAltScreen()).Run(); err != nil { - return "", err - } - if runner.cancelled { - return "", nil - } - if s, ok := runner.payload.(string); ok { - return s, nil - } - return strings.TrimSpace(m.input.Value()), nil -} diff --git a/internal/onboard/done.go b/internal/onboard/done.go deleted file mode 100644 index 189d98c..0000000 --- a/internal/onboard/done.go +++ /dev/null @@ -1,66 +0,0 @@ -package onboard - -// done.go — wizard-completion signaling. -// -// Sub-models (list, key input, model input, project field) used to call -// tea.Quit when the user pressed Enter / Ctrl+C. That kills the -// bubbletea Program — fine when each wizard runs in its own Program, -// but useless once we want one Program to host every wizard in -// sequence (no inter-wizard screen flash). -// -// The new contract: sub-models emit wizardDoneMsg via a tea.Cmd. The -// orchestrator (orchestrator.go) intercepts these and transitions to -// the next state. Standalone callers (runProjectKeyWizard, -// `openmelon setup`) wrap the sub-model in singleShotRunner, which -// translates wizardDoneMsg to tea.Quit and captures the result. - -import tea "github.com/charmbracelet/bubbletea" - -// wizardDoneMsg signals one wizard step finished. Payload is whatever -// the step produced (an int for list selections, a string for text -// inputs, etc.). Cancelled=true means the user bailed (Ctrl+C / Esc). -type wizardDoneMsg struct { - Cancelled bool - Payload any -} - -// finishWith returns a Cmd that emits wizardDoneMsg with payload. -func finishWith(payload any) tea.Cmd { - return func() tea.Msg { return wizardDoneMsg{Payload: payload} } -} - -// finishCancelled returns a Cmd that emits a cancellation -// wizardDoneMsg. -func finishCancelled() tea.Cmd { - return func() tea.Msg { return wizardDoneMsg{Cancelled: true} } -} - -// singleShotRunner hosts one sub-model and translates wizardDoneMsg -// into tea.Quit, capturing the payload + cancellation flag for the -// caller to read after Run() returns. -// -// Used by RunList / runKeyInput / runModelInput / runProjectField so -// they can keep their old "blocking call returns a value" contract -// while the orchestrator uses the same sub-models internally. -type singleShotRunner struct { - inner tea.Model - payload any - cancelled bool - finished bool -} - -func (s *singleShotRunner) Init() tea.Cmd { return s.inner.Init() } - -func (s *singleShotRunner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if d, ok := msg.(wizardDoneMsg); ok { - s.payload = d.Payload - s.cancelled = d.Cancelled - s.finished = true - return s, tea.Quit - } - var cmd tea.Cmd - s.inner, cmd = s.inner.Update(msg) - return s, cmd -} - -func (s *singleShotRunner) View() string { return s.inner.View() } diff --git a/internal/onboard/initproject.go b/internal/onboard/initproject.go deleted file mode 100644 index 009a6a6..0000000 --- a/internal/onboard/initproject.go +++ /dev/null @@ -1,183 +0,0 @@ -package onboard - -// initproject.go — wizard that creates a new project in cwd when no -// project is found there. - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -// EnsureProject runs the project-init wizard if cwd is not already in -// (or under) an openmelon project. Returns the project's workdir. -func EnsureProject(cwd string) (workdir string, err error) { - wd, err := projectx.Discover(cwd) - if err != nil { - return "", err - } - if wd != "" { - return wd, nil - } - - // Step 1: confirm. - res, err := RunList(listOpts{ - Title: headerStyle.Render(fmt.Sprintf("> No openmelon project found in %s", cwd)) + "\n\n" + - bodyStyle.Render("Create one here? It just adds a `.openmelon/` directory with a project.json,\nplus subdirs for characters, references, materials, and sessions."), - Items: []ListItem{ - {Title: "Yes, initialize a new project here"}, - {Title: "No, quit"}, - }, - Help: "enter to continue · esc to cancel", - }) - if err != nil { - return "", err - } - if res.Cancelled || res.Index != 0 { - return "", nil - } - - // Step 2: project id (slug). - defaultID := slugify(filepath.Base(cwd)) - id, err := runProjectField("Project id (kebab-case, this is the registry key)", defaultID) - if err != nil { - return "", err - } - if id == "" { - id = defaultID - } - if err := projectx.ValidateID(id); err != nil { - return "", fmt.Errorf("init: %w", err) - } - - // Step 3: name. - name, err := runProjectField("Project name (free text shown in the UI)", id) - if err != nil { - return "", err - } - if name == "" { - name = id - } - - // Step 4: description (optional). - description, err := runProjectField("One-line description (optional)", "") - if err != nil { - return "", err - } - - p, err := projectx.Init(cwd, id, name) - if err != nil { - return "", err - } - if description != "" { - p.Description = description - if err := projectx.Save(cwd, p); err != nil { - return "", err - } - } - if err := userconfig.Register(id, name, cwd); err != nil { - return "", fmt.Errorf("init: register: %w", err) - } - if err := userconfig.SetCurrent(id); err != nil { - return "", fmt.Errorf("init: set current: %w", err) - } - return cwd, nil -} - -// --- text-input helper --- - -type projectFieldModel struct { - prompt string - input textinput.Model - done bool - cancel bool -} - -func (m *projectFieldModel) Init() tea.Cmd { return textinput.Blink } - -func (m *projectFieldModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if k, ok := msg.(tea.KeyMsg); ok { - switch { - case key.Matches(k, key.NewBinding(key.WithKeys("ctrl+c", "esc"))): - m.cancel = true - return m, finishCancelled() - case key.Matches(k, key.NewBinding(key.WithKeys("enter"))): - m.done = true - return m, finishWith(strings.TrimSpace(m.input.Value())) - } - } - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd -} - -func (m *projectFieldModel) View() string { - var b strings.Builder - b.WriteString(headerStyle.Render(m.prompt)) - b.WriteString("\n\n") - b.WriteString(m.input.View()) - b.WriteString("\n\n") - b.WriteString(helpStyle.Render("enter to continue · esc to cancel")) - b.WriteString("\n") - return b.String() -} - -func runProjectField(prompt, def string) (string, error) { - ti := textinput.New() - ti.Placeholder = def - if def != "" { - ti.SetValue(def) - } - ti.CharLimit = 200 - ti.Width = 60 - ti.Focus() - m := &projectFieldModel{prompt: prompt, input: ti} - runner := &singleShotRunner{inner: m} - if _, err := tea.NewProgram(runner, tea.WithAltScreen()).Run(); err != nil { - return "", err - } - if runner.cancelled { - return "", fmt.Errorf("cancelled") - } - if s, ok := runner.payload.(string); ok { - return s, nil - } - return strings.TrimSpace(m.input.Value()), nil -} - -// slugify reduces a directory basename to a kebab-case project id. -// Mirrors cmd/openmelon/cmd_init.go's slugFromBase but lives here so -// we don't reach into cmd/. -func slugify(base string) string { - base = strings.ToLower(base) - var b strings.Builder - prevHy := false - for _, r := range base { - switch { - case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': - b.WriteRune(r) - prevHy = false - case r == ' ' || r == '_' || r == '-' || r == '.': - if !prevHy && b.Len() > 0 { - b.WriteByte('-') - prevHy = true - } - } - } - out := strings.Trim(b.String(), "-") - if out == "" || (out[0] < 'a' || out[0] > 'z') { - out = "project-" + out - out = strings.TrimRight(out, "-") - } - if len(out) < 2 { - out = "project" - } - return out -} diff --git a/internal/onboard/list.go b/internal/onboard/list.go deleted file mode 100644 index 1ad40c1..0000000 --- a/internal/onboard/list.go +++ /dev/null @@ -1,167 +0,0 @@ -// Package onboard runs first-launch and per-launch wizards: trust -// confirmation, API-key setup, and project initialization. Each wizard -// is its own short-lived bubbletea Program; onboard.Ensure stitches -// them together. -// -// We deliberately don't reuse the main TUI's Model — these screens are -// modal, single-purpose, and easier to read as small focused programs. -package onboard - -// list.go — reusable arrow-key list selector. Used by the trust prompt -// and the auth-provider picker. Modeled on the screens in Codex's -// onboarding (numbered list, ">" arrow on the active row, optional -// dim subtitle under each option). - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// ListItem is one selectable row. -type ListItem struct { - Title string // headline; rendered in the accent color when selected - Subtitle string // optional dim subtitle under the title -} - -// ListResult is what RunList returns. Index is -1 if the user -// cancelled (Ctrl+C / Esc / "q"). -type ListResult struct { - Index int - Cancelled bool -} - -// listOpts configures a list selector. -type listOpts struct { - Title string // shown as the header above the list - Help string // shown below the list (e.g. "Press enter to continue") - Items []ListItem - Initial int // initial selection -} - -// listModel is the bubbletea Model behind RunList. -type listModel struct { - opts listOpts - cursor int - chosen int - cancel bool - finished bool -} - -func (m *listModel) Init() tea.Cmd { return nil } - -func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if k, ok := msg.(tea.KeyMsg); ok { - switch { - case key.Matches(k, key.NewBinding(key.WithKeys("ctrl+c", "esc", "q"))): - m.cancel = true - m.finished = true - return m, finishCancelled() - case key.Matches(k, key.NewBinding(key.WithKeys("up", "k"))): - if m.cursor > 0 { - m.cursor-- - } - case key.Matches(k, key.NewBinding(key.WithKeys("down", "j"))): - if m.cursor < len(m.opts.Items)-1 { - m.cursor++ - } - case key.Matches(k, key.NewBinding(key.WithKeys("enter"))): - m.chosen = m.cursor - m.finished = true - return m, finishWith(m.cursor) - } - // Number-key shortcut: 1..9 picks that item. - if len(k.String()) == 1 && k.String()[0] >= '1' && k.String()[0] <= '9' { - n := int(k.String()[0]-'1') // 0-indexed - if n < len(m.opts.Items) { - m.cursor = n - m.chosen = n - m.finished = true - return m, finishWith(n) - } - } - } - return m, nil -} - -func (m *listModel) View() string { - var b strings.Builder - if m.opts.Title != "" { - b.WriteString(m.opts.Title) - b.WriteString("\n\n") - } - for i, it := range m.opts.Items { - arrow := " " - if i == m.cursor { - arrow = arrowStyle.Render("> ") - } - num := fmt.Sprintf("%d.", i+1) - title := it.Title - if i == m.cursor { - num = activeNumStyle.Render(num) - title = activeTitleStyle.Render(title) - } else { - num = numStyle.Render(num) - title = titleStyle.Render(title) - } - fmt.Fprintf(&b, "%s%s %s\n", arrow, num, title) - if it.Subtitle != "" { - fmt.Fprintf(&b, " %s\n", subtitleStyle.Render(it.Subtitle)) - } - if i < len(m.opts.Items)-1 { - b.WriteString("\n") - } - } - if m.opts.Help != "" { - b.WriteString("\n") - b.WriteString(helpStyle.Render(m.opts.Help)) - b.WriteString("\n") - } - return b.String() -} - -// RunList runs a list selector until the user picks or cancels. -// -// Standalone use: wraps the model in singleShotRunner so wizardDoneMsg -// → tea.Quit. Used by `openmelon project set-key`'s provider picker. -// The orchestrator (Run) does NOT use this — it hosts listModel -// directly so it can transition to the next state instead of quitting. -func RunList(opts listOpts) (ListResult, error) { - if opts.Initial < 0 || opts.Initial >= len(opts.Items) { - opts.Initial = 0 - } - m := &listModel{opts: opts, cursor: opts.Initial, chosen: -1} - runner := &singleShotRunner{inner: m} - if _, err := tea.NewProgram(runner, tea.WithAltScreen()).Run(); err != nil { - return ListResult{}, err - } - if runner.cancelled { - return ListResult{Index: -1, Cancelled: true}, nil - } - if idx, ok := runner.payload.(int); ok { - return ListResult{Index: idx}, nil - } - return ListResult{Index: m.chosen}, nil -} - -// --- styles (shared with the rest of onboard) --- - -var ( - accentColor = lipgloss.Color("4") - mutedColor = lipgloss.Color("8") - activeColor = lipgloss.Color("6") // cyan, like Codex's selection - - arrowStyle = lipgloss.NewStyle().Foreground(accentColor).Bold(true) - activeNumStyle = lipgloss.NewStyle().Foreground(activeColor).Bold(true) - activeTitleStyle = lipgloss.NewStyle().Foreground(activeColor).Bold(true) - numStyle = lipgloss.NewStyle() - titleStyle = lipgloss.NewStyle() - subtitleStyle = lipgloss.NewStyle().Foreground(mutedColor) - helpStyle = lipgloss.NewStyle().Foreground(mutedColor) - headerStyle = lipgloss.NewStyle().Bold(true) - pathStyle = lipgloss.NewStyle().Foreground(activeColor) - bodyStyle = lipgloss.NewStyle() -) diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go deleted file mode 100644 index 16e2aef..0000000 --- a/internal/onboard/onboard.go +++ /dev/null @@ -1,22 +0,0 @@ -package onboard - -// onboard.go — public entry point. -// -// Ensure() runs the full onboarding flow as a single bubbletea Program -// in alt-screen mode, transitioning smoothly between the trust, auth, -// and project-init screens. See orchestrator.go for the state machine. -// -// EnsureAuth and EnsureProject (formerly separate functions called from -// the old multi-Program Ensure) are kept as standalone helpers used by -// the `openmelon setup` and `openmelon project set-key` subcommands — -// those still want to drop into one specific wizard rather than the -// full onboarding flow. - -// Ensure runs trust → auth → project-init in one bubbletea Program. -// Steps are skipped silently when their precondition is already met -// (trusted dir, configured API key, existing project). -// -// Returns ASAP if the user declines a step. -func Ensure() (Result, error) { - return Run() -} diff --git a/internal/onboard/orchestrator.go b/internal/onboard/orchestrator.go deleted file mode 100644 index f9f1e9a..0000000 --- a/internal/onboard/orchestrator.go +++ /dev/null @@ -1,503 +0,0 @@ -package onboard - -// orchestrator.go — single bubbletea Program that hosts every onboarding -// wizard in sequence. Replaces the previous N-separate-Programs design, -// which left each wizard's output on screen because none used alt-screen -// and there was no way to reset between Programs. -// -// One Program → one alt-screen → smooth transitions: each wizard -// renders full-screen, the user picks, the screen updates in place to -// the next wizard. No flash, no scroll. - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -// State enumerates the ordered wizard screens. Init() picks the first -// state whose precondition is unmet; transitions advance to the next -// applicable state until StateDone. -type State int - -const ( - stateTrust State = iota - stateAuthProvider - stateAuthReuseEnv - stateAuthKey - stateAuthLLMModel - stateAuthImageModel - stateProjectConfirm - stateProjectID - stateProjectName - stateProjectDesc - stateDone -) - -// Result is what the orchestrator returns from Run. -type Result struct { - // Workdir is the project root (existing or freshly initialized). - // Empty when Quit is true. - Workdir string - - // Quit is true when the user explicitly cancelled at any step. The - // caller should exit cleanly with no error. - Quit bool -} - -// orchestrator hosts one wizard at a time and steps through the state -// machine. It implements tea.Model so it can be passed to tea.NewProgram. -type orchestrator struct { - state State - cwd string - inner tea.Model - width int - height int - - // preconditions cached on Init. - cfg *userconfig.Config - creds *userconfig.Credentials - skipTrust bool - skipAuth bool - skipProject bool - - // accumulated answers carried forward. - chosenProvider int - envKey string // set when we detect an env var for the chosen provider - apiKey string - llmModel string - imageModel string - - projectID string - projectName string - projectDesc string - - // workdir for the resulting project. Set in stateProjectConfirm - // (existing project) or after stateProjectDesc (newly created). - workdir string - - cancelled bool -} - -// Run launches the orchestrator. Blocks until the user finishes or -// cancels. Returns the project workdir. -func Run() (Result, error) { - cwd, err := os.Getwd() - if err != nil { - return Result{}, fmt.Errorf("onboard: cwd: %w", err) - } - - o := &orchestrator{cwd: cwd} - if err := o.preflight(); err != nil { - return Result{}, err - } - if o.skipTrust && o.skipAuth && o.skipProject { - // All preconditions already met — nothing to do, no need to - // even paint the alt-screen. - return Result{Workdir: o.workdir}, nil - } - - if _, err := tea.NewProgram(o, tea.WithAltScreen()).Run(); err != nil { - return Result{}, err - } - if o.cancelled { - return Result{Quit: true}, nil - } - return Result{Workdir: o.workdir}, nil -} - -// preflight computes which states need to run + caches loaded state. -// -// Trust: skipped if cwd is in cfg.TrustedDirs (or under one) -// Auth: skipped if any global API key is configured -// Project: skipped if cwd is in (or under) an existing project -func (o *orchestrator) preflight() error { - cfg, err := userconfig.LoadConfig() - if err != nil { - return err - } - o.cfg = cfg - o.skipTrust = cfg.IsTrusted(o.cwd) - - wd, err := projectx.Discover(o.cwd) - if err != nil { - return err - } - if wd != "" { - o.skipProject = true - o.workdir = wd - } - - creds, err := userconfig.LoadCredentials() - if err != nil { - return err - } - o.creds = creds - o.skipAuth = len(creds.APIKeys) > 0 - if !o.skipAuth && wd != "" { - o.skipAuth = projectHasProviderConfig(wd) - } - return nil -} - -func projectHasProviderConfig(workdir string) bool { - p, err := projectx.Load(workdir) - if err != nil { - return false - } - provider := p.Defaults.LLMProvider - model := p.Defaults.LLMModel - if provider == "" || provider == "auto" || model == "" { - return false - } - resolved := userconfig.ResolveProvider(workdir, provider) - return resolved.APIKey != "" -} - -// Init picks the first state that needs attention. -func (o *orchestrator) Init() tea.Cmd { - o.state = o.firstState() - if o.state == stateDone { - return tea.Quit - } - o.inner = o.makeFor(o.state) - if o.inner == nil { - return tea.Quit - } - return o.inner.Init() -} - -// firstState scans the preconditions and returns the first unmet one. -func (o *orchestrator) firstState() State { - if !o.skipTrust { - return stateTrust - } - if !o.skipAuth { - return stateAuthProvider - } - if !o.skipProject { - return stateProjectConfirm - } - return stateDone -} - -// Update routes input messages to the active inner model. When inner -// emits a wizardDoneMsg, capture the answer + advance state. -func (o *orchestrator) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if w, ok := msg.(tea.WindowSizeMsg); ok { - o.width, o.height = w.Width, w.Height - } - if d, ok := msg.(wizardDoneMsg); ok { - if d.Cancelled { - o.cancelled = true - return o, tea.Quit - } - next, err := o.captureAndAdvance(d.Payload) - if err != nil { - fmt.Fprintf(os.Stderr, "onboard: %v\n", err) - o.cancelled = true - return o, tea.Quit - } - o.state = next - if next == stateDone { - return o, tea.Quit - } - o.inner = o.makeFor(next) - if o.inner == nil { - return o, tea.Quit - } - return o, o.inner.Init() - } - if o.inner == nil { - return o, nil - } - var cmd tea.Cmd - o.inner, cmd = o.inner.Update(msg) - return o, cmd -} - -func (o *orchestrator) View() string { - if o.inner == nil { - return "" - } - return o.inner.View() -} - -// captureAndAdvance stores the wizard's answer + decides what state -// comes next. Returns stateDone when the orchestrator is finished. -func (o *orchestrator) captureAndAdvance(payload any) (State, error) { - switch o.state { - case stateTrust: - idx, _ := payload.(int) - if idx != 0 { - o.cancelled = true - return stateDone, nil - } - o.cfg.AddTrusted(o.cwd) - if err := userconfig.SaveConfig(o.cfg); err != nil { - return stateDone, fmt.Errorf("trust: save: %w", err) - } - // Re-evaluate next preconditions. - if !o.skipAuth { - return stateAuthProvider, nil - } - if !o.skipProject { - return stateProjectConfirm, nil - } - return stateDone, nil - - case stateAuthProvider: - idx, _ := payload.(int) - o.chosenProvider = idx - // If the env var for this provider is set, jump to "reuse?". - envVar := providerOptions[idx].envVar - if v := os.Getenv(envVar); v != "" { - o.envKey = v - return stateAuthReuseEnv, nil - } - return stateAuthKey, nil - - case stateAuthReuseEnv: - idx, _ := payload.(int) - if idx == 0 { // "Yes, use it" - o.apiKey = o.envKey - return stateAuthLLMModel, nil - } - return stateAuthKey, nil - - case stateAuthKey: - s, _ := payload.(string) - if s == "" { - o.cancelled = true - return stateDone, nil - } - o.apiKey = s - return stateAuthLLMModel, nil - - case stateAuthLLMModel: - s, _ := payload.(string) - if s == "" { - s = providerOptions[o.chosenProvider].defaultLLMModel - } - o.llmModel = s - if providerOptions[o.chosenProvider].imgProvider != "" { - return stateAuthImageModel, nil - } - // Anthropic etc. — no image step. Persist + advance. - if err := o.persistAuth(); err != nil { - return stateDone, err - } - if !o.skipProject { - return stateProjectConfirm, nil - } - return stateDone, nil - - case stateAuthImageModel: - o.imageModel, _ = payload.(string) - if err := o.persistAuth(); err != nil { - return stateDone, err - } - if !o.skipProject { - return stateProjectConfirm, nil - } - return stateDone, nil - - case stateProjectConfirm: - idx, _ := payload.(int) - if idx != 0 { - o.cancelled = true - return stateDone, nil - } - return stateProjectID, nil - - case stateProjectID: - s, _ := payload.(string) - if s == "" { - s = slugify(filepath.Base(o.cwd)) - } - if err := projectx.ValidateID(s); err != nil { - return stateDone, err - } - o.projectID = s - return stateProjectName, nil - - case stateProjectName: - s, _ := payload.(string) - if s == "" { - s = o.projectID - } - o.projectName = s - return stateProjectDesc, nil - - case stateProjectDesc: - o.projectDesc, _ = payload.(string) - if err := o.persistProject(); err != nil { - return stateDone, fmt.Errorf("project init: %w", err) - } - return stateDone, nil - } - return stateDone, nil -} - -// persistAuth writes the chosen provider's key to credentials.json + -// the chosen models to config.json.defaults. -func (o *orchestrator) persistAuth() error { - chosen := providerOptions[o.chosenProvider] - if err := userconfig.SetAPIKey(chosen.slug, o.apiKey); err != nil { - return fmt.Errorf("auth: save key: %w", err) - } - cfg, err := userconfig.LoadConfig() - if err != nil { - return err - } - cfg.Defaults.LLMProvider = chosen.slug - cfg.Defaults.LLMModel = o.llmModel - if o.imageModel != "" && chosen.imgProvider != "" { - cfg.Defaults.ImageProvider = chosen.imgProvider - cfg.Defaults.ImageModel = o.imageModel - } - if err := userconfig.SaveConfig(cfg); err != nil { - return fmt.Errorf("auth: save config: %w", err) - } - o.cfg = cfg - return nil -} - -// persistProject creates the project on disk + registers it. -func (o *orchestrator) persistProject() error { - p, err := projectx.Init(o.cwd, o.projectID, o.projectName) - if err != nil { - return err - } - if o.projectDesc != "" { - p.Description = o.projectDesc - if err := projectx.Save(o.cwd, p); err != nil { - return err - } - } - if err := userconfig.Register(o.projectID, o.projectName, o.cwd); err != nil { - return err - } - if err := userconfig.SetCurrent(o.projectID); err != nil { - return err - } - o.workdir = o.cwd - return nil -} - -// makeFor builds the inner Model for a given state. -func (o *orchestrator) makeFor(s State) tea.Model { - switch s { - case stateTrust: - header := headerStyle.Render(fmt.Sprintf("> You are in %s", pathStyle.Render(o.cwd))) - body := bodyStyle.Render(strings.Join([]string{ - "", - "Do you trust the contents of this directory?", - "openmelon will read project files, registered characters and references,", - "and may invoke tools the agent decides to call. Only continue if you trust", - "the contents here.", - }, "\n")) - return &listModel{ - opts: listOpts{ - Title: header + "\n" + body, - Items: []ListItem{{Title: "Yes, continue"}, {Title: "No, quit"}}, - Help: "↑/↓ to choose · 1/2 shortcut · enter to continue · ctrl+c to quit", - }, - chosen: -1, - } - - case stateAuthProvider: - header := headerStyle.Render("Welcome to openmelon") + "\n\n" + - bodyStyle.Render("openmelon needs an API key to talk to a model.\nPick one provider — you can change later with `openmelon setup`.") - items := make([]ListItem, len(providerOptions)) - for i, p := range providerOptions { - items[i] = ListItem{Title: p.title, Subtitle: p.subtitle} - } - return &listModel{ - opts: listOpts{ - Title: header, - Items: items, - Help: "↑/↓ to choose · 1/2/3 shortcut · enter to continue · ctrl+c to cancel", - }, - chosen: -1, - } - - case stateAuthReuseEnv: - p := providerOptions[o.chosenProvider] - masked := o.envKey - if len(masked) > 8 { - masked = masked[:4] + "…" + masked[len(masked)-4:] - } - return &listModel{ - opts: listOpts{ - Title: headerStyle.Render(fmt.Sprintf("Detected %s in your environment", p.envVar)) + "\n\n" + - bodyStyle.Render(fmt.Sprintf("Use %s as the %s key?", masked, p.title[len("Use "):])), - Items: []ListItem{ - {Title: "Yes, use it"}, - {Title: "No, paste a different one"}, - }, - Help: "enter to continue · esc to cancel", - }, - chosen: -1, - } - - case stateAuthKey: - return newKeyInputModel(providerOptions[o.chosenProvider]) - - case stateAuthLLMModel: - return newModelInputModel("LLM model", providerOptions[o.chosenProvider].defaultLLMModel) - - case stateAuthImageModel: - return newModelInputModel( - "Image model (leave blank to skip image generation)", - providerOptions[o.chosenProvider].defaultImgModel, - ) - - case stateProjectConfirm: - return &listModel{ - opts: listOpts{ - Title: headerStyle.Render(fmt.Sprintf("> No openmelon project found in %s", o.cwd)) + "\n\n" + - bodyStyle.Render("Create one here? It just adds a `.openmelon/` directory with a project.json,\nplus subdirs for characters, references, materials, and sessions."), - Items: []ListItem{ - {Title: "Yes, initialize a new project here"}, - {Title: "No, quit"}, - }, - Help: "enter to continue · esc to cancel", - }, - chosen: -1, - } - - case stateProjectID: - def := slugify(filepath.Base(o.cwd)) - return newProjectField("Project id (kebab-case, the registry key)", def) - - case stateProjectName: - return newProjectField("Project name (free text shown in the UI)", o.projectID) - - case stateProjectDesc: - return newProjectField("One-line description (optional)", "") - } - return nil -} - -// newProjectField is the constructor formerly inlined in -// runProjectField. Lives here so the orchestrator can build one without -// going through the standalone Run helper. -func newProjectField(prompt, def string) *projectFieldModel { - ti := textinput.New() - ti.Placeholder = def - if def != "" { - ti.SetValue(def) - } - ti.CharLimit = 200 - ti.Width = 60 - ti.Focus() - return &projectFieldModel{prompt: prompt, input: ti} -} diff --git a/internal/onboard/projectkey.go b/internal/onboard/projectkey.go deleted file mode 100644 index c94c737..0000000 --- a/internal/onboard/projectkey.go +++ /dev/null @@ -1,67 +0,0 @@ -package onboard - -// projectkey.go — interactive wizard for `openmelon project set-key`. -// -// Same look and feel as the auth wizard but scoped to one project: pick -// provider, paste key (masked), save into /.openmelon/credentials.json. - -import ( - "fmt" - "os" - - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -// RunProjectKeyWizard runs the per-project key wizard. If providerHint -// is non-empty, the provider picker is skipped and the wizard goes -// straight to the key input for that provider. -// -// Returns (provider, ok, err). ok=false means the user cancelled — the -// caller should treat that as a clean no-op. -func RunProjectKeyWizard(workdir, providerHint string) (provider string, ok bool, err error) { - chosen := -1 - if providerHint != "" { - for i, p := range providerOptions { - if p.slug == providerHint { - chosen = i - break - } - } - if chosen < 0 { - return "", false, fmt.Errorf("unknown provider %q (supported: openrouter, openai, anthropic)", providerHint) - } - } else { - header := headerStyle.Render(fmt.Sprintf("Set a project-scoped key for %s", workdir)) + "\n\n" + - bodyStyle.Render("Pick the provider this key is for. The key will be stored at\n/.openmelon/credentials.json (mode 0600) and override the global key when you run openmelon in this project.") - items := make([]ListItem, len(providerOptions)) - for i, p := range providerOptions { - items[i] = ListItem{Title: p.title, Subtitle: p.subtitle} - } - res, err := RunList(listOpts{ - Title: header, - Items: items, - Help: "↑/↓ to choose · 1/2/3 shortcut · enter to continue · ctrl+c to cancel", - }) - if err != nil { - return "", false, err - } - if res.Cancelled { - return "", false, nil - } - chosen = res.Index - } - - p := providerOptions[chosen] - apiKey, err := runKeyInput(p) - if err != nil { - return "", false, err - } - if apiKey == "" { - return "", false, nil - } - if err := userconfig.SetProjectAPIKey(workdir, p.slug, apiKey); err != nil { - return "", false, fmt.Errorf("save: %w", err) - } - fmt.Fprintf(os.Stderr, "Project key saved for %s.\n", p.slug) - return p.slug, true, nil -} diff --git a/internal/onboard/providers.go b/internal/onboard/providers.go deleted file mode 100644 index 81e622a..0000000 --- a/internal/onboard/providers.go +++ /dev/null @@ -1,62 +0,0 @@ -package onboard - -// providers.go — public re-exposes of the provider/preset metadata so -// the TUI's /model and /model-image selectors can render the same -// curated lists the auth wizard uses. - -// Preset is one curated model id with a one-line description. -type Preset struct { - ID string - Subtitle string -} - -// ProviderInfo is the public view of one provider known to openmelon. -// Mirrors the internal providerOption — kept narrow on purpose so we -// can extend providerOption later without breaking the public API. -type ProviderInfo struct { - Slug string - DefaultLLMModel string - DefaultImageModel string - // ImageProvider is the slug used for the image-gen client. Often - // equal to Slug; "" means the provider has no image support. - ImageProvider string - LLMPresets []Preset - ImagePresets []Preset -} - -// Providers returns every provider openmelon knows how to talk to, -// in the canonical order shown in the auth wizard. -func Providers() []ProviderInfo { - out := make([]ProviderInfo, 0, len(providerOptions)) - for _, p := range providerOptions { - out = append(out, toPublic(p)) - } - return out -} - -// ProviderBySlug looks up a provider by its slug ("openrouter" / -// "openai" / "anthropic"). Returns ok=false if not registered. -func ProviderBySlug(slug string) (ProviderInfo, bool) { - for _, p := range providerOptions { - if p.slug == slug { - return toPublic(p), true - } - } - return ProviderInfo{}, false -} - -func toPublic(p providerOption) ProviderInfo { - out := ProviderInfo{ - Slug: p.slug, - DefaultLLMModel: p.defaultLLMModel, - DefaultImageModel: p.defaultImgModel, - ImageProvider: p.imgProvider, - } - for _, ms := range p.llmPresets { - out.LLMPresets = append(out.LLMPresets, Preset{ID: ms.id, Subtitle: ms.subtitle}) - } - for _, ms := range p.imagePresets { - out.ImagePresets = append(out.ImagePresets, Preset{ID: ms.id, Subtitle: ms.subtitle}) - } - return out -} diff --git a/internal/onboard/trust.go b/internal/onboard/trust.go deleted file mode 100644 index 584e03b..0000000 --- a/internal/onboard/trust.go +++ /dev/null @@ -1,59 +0,0 @@ -package onboard - -// trust.go — first wizard. Prompts the user to confirm trust on the -// current working directory before any agent loop runs. - -import ( - "fmt" - "os" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/userconfig" -) - -// EnsureTrust runs the trust prompt if cwd is not in the trusted list. -// Returns: -// trusted=true → proceed -// trusted=false → user declined; caller should exit cleanly -// -// Adds cwd to the trusted_dirs list when the user confirms. -func EnsureTrust(cwd string) (trusted bool, err error) { - cfg, err := userconfig.LoadConfig() - if err != nil { - return false, err - } - if cfg.IsTrusted(cwd) { - return true, nil - } - - header := headerStyle.Render(fmt.Sprintf("> You are in %s", pathStyle.Render(cwd))) - body := bodyStyle.Render(strings.Join([]string{ - "", - "Do you trust the contents of this directory?", - "openmelon will read project files, registered characters and references,", - "and may invoke tools the agent decides to call. Only continue if you trust", - "the contents here.", - }, "\n")) - - res, err := RunList(listOpts{ - Title: header + "\n" + body, - Items: []ListItem{ - {Title: "Yes, continue"}, - {Title: "No, quit"}, - }, - Help: "↑/↓ to choose · 1/2 shortcut · enter to continue · ctrl+c to quit", - }) - if err != nil { - return false, err - } - if res.Cancelled || res.Index != 0 { - fmt.Fprintln(os.Stderr, "openmelon: not trusted; exiting") - return false, nil - } - - cfg.AddTrusted(cwd) - if err := userconfig.SaveConfig(cfg); err != nil { - return false, fmt.Errorf("trust: save config: %w", err) - } - return true, nil -} diff --git a/internal/parity/creator_workflow_test.go b/internal/parity/creator_workflow_test.go deleted file mode 100644 index 2079a28..0000000 --- a/internal/parity/creator_workflow_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package parity - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/continuity" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -func TestCreatorWorkflow_NewTopicCreatesDraftButNotEpisode(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - reg := tools.NewRegistry() - tools.RegisterAll(reg, &tools.Env{Workdir: wd, Project: proj}) - model := &scriptedModel{t: t, responses: []llm.ChatResponse{ - toolResponse("c1", "create_space", `{ - "id":"tennis-anime", - "name":"Tennis Anime", - "description":"Anime tennis lessons", - "assumptions":"# Assumptions\n\n- Provisional cheerful anime style.\n" - }`), - stopResponse("I created a draft space and need confirmation before episodes."), - }} - rt := &runtime.Runtime{LLM: model, Registry: reg} - res, err := rt.Run(context.Background(), runtime.RunInput{SystemPrompt: "creator", UserInput: "Start a tennis anime lesson series"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if !res.Finished { - t.Fatal("run did not finish") - } - sp, err := continuity.GetSpace(wd, "tennis-anime") - if err != nil { - t.Fatalf("space missing: %v", err) - } - if sp.Status != "draft" { - t.Fatalf("space status = %q, want draft", sp.Status) - } - if _, err := continuity.CreateEpisode(wd, "tennis-anime", continuity.Episode{ID: "should-fail", Topic: "too soon"}); err == nil { - t.Fatal("draft space allowed durable episode") - } - canon, _ := continuity.ReadCanon(wd, "tennis-anime") - if strings.Contains(canon, "cheerful anime") { - t.Fatalf("assumption leaked into canon: %s", canon) - } -} - -func TestCreatorWorkflow_ActivationEnablesEpisodeAndFeedbackContext(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := continuity.CreateSpace(wd, continuity.CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatalf("create space: %v", err) - } - if _, _, err := continuity.ActivateSpace(wd, "tennis-anime", continuity.Decision{Decision: "User confirmed anime tennis lessons."}); err != nil { - t.Fatalf("activate: %v", err) - } - if _, err := continuity.CreateEpisode(wd, "tennis-anime", continuity.Episode{ID: "serve-basics", Topic: "Serve basics"}); err != nil { - t.Fatalf("episode: %v", err) - } - if _, err := continuity.RecordFeedback(wd, "tennis-anime", continuity.Feedback{ - EpisodeID: "serve-basics", - Signal: "pace_too_fast", - Recommendation: "Use fewer terms next episode.", - }); err != nil { - t.Fatalf("feedback: %v", err) - } - packet, err := continuity.BuildContextPacket(wd, proj.ID, "tennis-anime") - if err != nil { - t.Fatalf("packet: %v", err) - } - if packet.Space.Status != "active" || len(packet.RecentEpisodes) != 1 || len(packet.RecentFeedback) != 1 { - t.Fatalf("context packet missing active continuity: %+v", packet) - } - if packet.RecentFeedback[0].Signal != "pace_too_fast" { - t.Fatalf("feedback not preserved: %+v", packet.RecentFeedback) - } -} - -func TestCreatorWorkflow_ReuseExistingSpaceAndRankAssets(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := continuity.CreateSpace(wd, continuity.CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime", Tags: []string{"tennis"}}); err != nil { - t.Fatalf("create tennis: %v", err) - } - if _, _, err := continuity.ActivateSpace(wd, "tennis-anime", continuity.Decision{Decision: "confirmed"}); err != nil { - t.Fatalf("activate: %v", err) - } - if _, err := continuity.CreateSpace(wd, continuity.CreateSpaceOptions{ID: "food-review", Name: "Food Review", Tags: []string{"food"}}); err != nil { - t.Fatalf("create food: %v", err) - } - if _, err := continuity.RegisterAsset(wd, "tennis-anime", continuity.Asset{ID: "weak-bg", Description: "experimental court", Weight: 0.2}); err != nil { - t.Fatalf("weak asset: %v", err) - } - if _, err := continuity.RegisterAsset(wd, "tennis-anime", continuity.Asset{ID: "hero-bg", Description: "canonical court", Weight: 2.0}); err != nil { - t.Fatalf("hero asset: %v", err) - } - hits, err := continuity.SearchSpaces(wd, "continue tennis") - if err != nil { - t.Fatalf("search: %v", err) - } - if len(hits) == 0 || hits[0].Space.ID != "tennis-anime" { - t.Fatalf("did not rank existing tennis space first: %+v", hits) - } - packet, err := continuity.BuildContextPacket(wd, proj.ID, "tennis-anime") - if err != nil { - t.Fatalf("packet: %v", err) - } - if len(packet.Assets) < 2 || packet.Assets[0].ID != "hero-bg" { - t.Fatalf("assets not ranked by weight: %+v", packet.Assets) - } -} - -func TestCreatorWorkflow_FeedbackCanDemoteAsset(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := continuity.CreateSpace(wd, continuity.CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatalf("create space: %v", err) - } - if _, err := continuity.RegisterAsset(wd, "tennis-anime", continuity.Asset{ID: "drifty-room", Description: "room with drift", Weight: 2.0}); err != nil { - t.Fatalf("asset a: %v", err) - } - if _, err := continuity.RegisterAsset(wd, "tennis-anime", continuity.Asset{ID: "stable-room", Description: "stable room", Weight: 1.0}); err != nil { - t.Fatalf("asset b: %v", err) - } - if _, err := continuity.UpdateAssetWeight(wd, "tennis-anime", "drifty-room", 0.1, "archived"); err != nil { - t.Fatalf("demote: %v", err) - } - packet, err := continuity.BuildContextPacket(wd, "creator", "tennis-anime") - if err != nil { - t.Fatalf("packet: %v", err) - } - if packet.Assets[0].ID != "stable-room" { - t.Fatalf("demoted asset still ranked first: %+v", packet.Assets) - } -} - -func TestCreatorWorkflow_SelectedContextCarriesBudgetAndReasons(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := continuity.CreateSpace(wd, continuity.CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatalf("create space: %v", err) - } - if _, _, err := continuity.ActivateSpace(wd, "tennis-anime", continuity.Decision{Decision: "confirmed"}); err != nil { - t.Fatalf("activate: %v", err) - } - for _, d := range []string{"visual style", "voice", "episode format"} { - if _, err := continuity.RecordDecision(wd, "tennis-anime", continuity.Decision{Decision: d}); err != nil { - t.Fatalf("decision: %v", err) - } - } - packet, err := continuity.BuildSelectedContextPacket(wd, proj.ID, "tennis-anime", continuity.SelectionOptions{Query: "continue", MaxDecisions: 1}) - if err != nil { - t.Fatalf("packet: %v", err) - } - if packet.Selection == nil || packet.Selection.DecisionLimit != 1 || len(packet.Selection.Reasons) == 0 { - t.Fatalf("selection metadata missing: %+v", packet.Selection) - } - if len(packet.RecentDecisions) != 1 || len(packet.Selection.Truncated) == 0 { - t.Fatalf("selected context did not respect budget: %+v", packet) - } -} - -func TestCreatorWorkflow_MemoryStaysProvisionalUntilPromoted(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "creator", "Creator"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := continuity.CreateSpace(wd, continuity.CreateSpaceOptions{ID: "tennis-anime", Name: "Tennis Anime"}); err != nil { - t.Fatalf("create space: %v", err) - } - if _, err := continuity.RecordMemoryItem(wd, "tennis-anime", continuity.MemoryItem{ - ID: "mem-calm", - Content: "Maybe use calmer explanations.", - }); err != nil { - t.Fatalf("memory: %v", err) - } - packet, err := continuity.BuildContextPacket(wd, "creator", "tennis-anime") - if err != nil { - t.Fatalf("packet: %v", err) - } - if len(packet.RecentDecisions) != 0 { - t.Fatalf("provisional memory became decision: %+v", packet.RecentDecisions) - } - if _, err := continuity.PromoteMemoryItem(wd, "tennis-anime", continuity.MemoryPromotion{ - ItemID: "mem-calm", - Decision: "Use calmer explanations.", - }); err != nil { - t.Fatalf("promote: %v", err) - } - packet, err = continuity.BuildContextPacket(wd, "creator", "tennis-anime") - if err != nil { - t.Fatalf("packet after promote: %v", err) - } - if len(packet.RecentDecisions) != 1 || !strings.Contains(packet.RecentDecisions[0].Decision, "calmer") { - t.Fatalf("promotion did not create decision: %+v", packet.RecentDecisions) - } -} - -func TestCreatorWorkflow_PendingInputAppliesBeforeNextModelCall(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{Name: "noop", Description: "noop", Parameters: json.RawMessage(`{"type":"object"}`)}, - Handler: func(context.Context, json.RawMessage) (any, error) { - return map[string]any{"ok": true}, nil - }, - }) - model := &scriptedModel{t: t, responses: []llm.ChatResponse{ - toolResponse("c1", "noop", `{}`), - stopResponse("done"), - }} - drains := 0 - rt := &runtime.Runtime{ - LLM: model, - Registry: reg, - DrainUserInput: func() []string { - drains++ - if drains == 2 { - return []string{"Make the next shot more playful."} - } - return nil - }, - } - if _, err := rt.Run(context.Background(), runtime.RunInput{SystemPrompt: "creator", UserInput: "continue"}); err != nil { - t.Fatalf("Run: %v", err) - } - if len(model.requests) != 2 { - t.Fatalf("requests = %d, want 2", len(model.requests)) - } - got := model.requests[1].Messages[len(model.requests[1].Messages)-1] - if got.Role != llm.RoleUser || got.Content != "Make the next shot more playful." { - t.Fatalf("pending input not included before next model call: %+v", got) - } -} - -type scriptedModel struct { - t *testing.T - responses []llm.ChatResponse - requests []llm.ChatRequest -} - -func (m *scriptedModel) Chat(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { - if len(m.responses) == 0 { - m.t.Fatal("scripted model ran out of responses") - } - m.requests = append(m.requests, req) - resp := m.responses[0] - m.responses = m.responses[1:] - return &resp, nil -} - -func toolResponse(id, name, args string) llm.ChatResponse { - return llm.ChatResponse{ - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ID: id, Name: name, Arguments: json.RawMessage(args)}}, - }, - FinishReason: llm.FinishToolCalls, - } -} - -func stopResponse(text string) llm.ChatResponse { - return llm.ChatResponse{ - Message: llm.Message{Role: llm.RoleAssistant, Content: text}, - FinishReason: llm.FinishStop, - } -} diff --git a/internal/policy/policy.go b/internal/policy/policy.go deleted file mode 100644 index 2932898..0000000 --- a/internal/policy/policy.go +++ /dev/null @@ -1,101 +0,0 @@ -package policy - -import ( - "context" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -type Decision string - -const ( - Allow Decision = "allow" - Ask Decision = "ask" - Deny Decision = "deny" -) - -type Request struct { - Action string - Tool string - Workdir string - SpaceID string - TargetPath string - Command string - Description string - Binary string -} - -type Response struct { - Decision Decision - Reason string -} - -type Enforcer interface { - Check(context.Context, Request) Response -} - -type DefaultEnforcer struct { - BashMode projectx.BashPermissionMode - IsBashAllowed func(binary string) bool - JudgeBash func(ctx context.Context, command, description string) BashJudgement -} - -type BashJudgement int - -const ( - BashAsk BashJudgement = iota - BashAuto - BashBlock -) - -func (e DefaultEnforcer) Check(ctx context.Context, req Request) Response { - switch req.Action { - case "bash.execute": - return e.checkBash(ctx, req) - case "continuity.write": - return Response{Decision: Allow} - default: - return Response{Decision: Ask, Reason: "unknown action requires approval"} - } -} - -func (e DefaultEnforcer) checkBash(ctx context.Context, req Request) Response { - mode := e.BashMode - if mode == "" { - mode = projectx.BashModeStrict - } - if mode == projectx.BashModeTrusted { - return Response{Decision: Allow, Reason: "trusted"} - } - if e.IsBashAllowed != nil && e.IsBashAllowed(req.Binary) { - return Response{Decision: Allow, Reason: "allowlisted"} - } - verdict := BashAsk - if e.JudgeBash != nil { - verdict = e.JudgeBash(ctx, req.Command, req.Description) - } - if verdict == BashBlock { - return Response{Decision: Deny, Reason: "blocked by safety judge"} - } - if mode == projectx.BashModeAuto && verdict == BashAuto { - return Response{Decision: Allow, Reason: "judge:auto"} - } - return Response{Decision: Ask} -} - -func NormalizeDecision(d Decision) Decision { - switch d { - case Allow, Ask, Deny: - return d - default: - return Ask - } -} - -func ReasonOrDefault(reason, fallback string) string { - if strings.TrimSpace(reason) != "" { - return reason - } - return fallback -} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go deleted file mode 100644 index 658963a..0000000 --- a/internal/policy/policy_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package policy - -import ( - "context" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -func TestDefaultEnforcerBashModes(t *testing.T) { - ctx := context.Background() - req := Request{Action: "bash.execute", Command: "ls", Description: "inspect", Binary: "ls"} - - if got := (DefaultEnforcer{}).Check(ctx, req); got.Decision != Ask { - t.Fatalf("strict default = %s, want ask", got.Decision) - } - if got := (DefaultEnforcer{BashMode: projectx.BashModeTrusted}).Check(ctx, req); got.Decision != Allow || got.Reason != "trusted" { - t.Fatalf("trusted = %+v, want allow/trusted", got) - } - if got := (DefaultEnforcer{ - BashMode: projectx.BashModeAuto, - JudgeBash: func(context.Context, string, string) BashJudgement { - return BashAuto - }, - }).Check(ctx, req); got.Decision != Allow || got.Reason != "judge:auto" { - t.Fatalf("auto judge = %+v, want allow/judge:auto", got) - } - if got := (DefaultEnforcer{ - JudgeBash: func(context.Context, string, string) BashJudgement { - return BashBlock - }, - }).Check(ctx, req); got.Decision != Deny { - t.Fatalf("blocked = %+v, want deny", got) - } -} - -func TestDefaultEnforcerContinuityWriteAllowed(t *testing.T) { - got := (DefaultEnforcer{}).Check(context.Background(), Request{Action: "continuity.write", Tool: "record_decision"}) - if got.Decision != Allow { - t.Fatalf("continuity write = %+v, want allow", got) - } -} diff --git a/internal/project/load.go b/internal/project/load.go deleted file mode 100644 index 9d9ac84..0000000 --- a/internal/project/load.go +++ /dev/null @@ -1,41 +0,0 @@ -package project - -import ( - "encoding/json" - "fmt" - "os" -) - -// Load reads a project JSON file from path, validates required fields, and returns a Project. -// Returns a wrapped os.ErrNotExist error if the file does not exist. -// Returns a descriptive error if required fields are missing. -func Load(path string) (*Project, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("load project %q: %w", path, err) - } - - var p Project - if err := json.Unmarshal(data, &p); err != nil { - return nil, fmt.Errorf("parse project %q: %w", path, err) - } - - if err := validate(&p); err != nil { - return nil, fmt.Errorf("invalid project %q: %w", path, err) - } - - return &p, nil -} - -func validate(p *Project) error { - if p.ID == "" { - return fmt.Errorf("missing required field: id") - } - if p.Name == "" { - return fmt.Errorf("missing required field: name") - } - if p.Platform == "" { - return fmt.Errorf("missing required field: platform") - } - return nil -} diff --git a/internal/project/load_test.go b/internal/project/load_test.go deleted file mode 100644 index ed8a47e..0000000 --- a/internal/project/load_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package project - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoad(t *testing.T) { - tests := []struct { - name string - json string - wantID string - wantErrText string - wantNotExist bool - }{ - { - name: "valid project", - json: `{ - "id": "test-proj", - "name": "Test Project", - "platform": "xiaohongshu", - "audience": "food lovers", - "persona": "casual reviewer" - }`, - wantID: "test-proj", - }, - { - name: "missing required field id", - json: `{"name": "No ID", "platform": "xiaohongshu"}`, - wantErrText: "missing required field: id", - }, - { - name: "missing required field name", - json: `{"id": "proj-1", "platform": "xiaohongshu"}`, - wantErrText: "missing required field: name", - }, - { - name: "missing required field platform", - json: `{"id": "proj-1", "name": "Proj"}`, - wantErrText: "missing required field: platform", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "project.json") - if err := os.WriteFile(path, []byte(tc.json), 0o644); err != nil { - t.Fatal(err) - } - - p, err := Load(path) - if tc.wantErrText != "" { - if err == nil { - t.Fatalf("expected error containing %q, got nil", tc.wantErrText) - } - if !strings.Contains(err.Error(), tc.wantErrText) { - t.Fatalf("expected error %q to contain %q", err.Error(), tc.wantErrText) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.ID != tc.wantID { - t.Errorf("ID = %q, want %q", p.ID, tc.wantID) - } - }) - } -} - -func TestLoad_fileNotFound(t *testing.T) { - _, err := Load("/nonexistent/path/project.json") - if err == nil { - t.Fatal("expected error, got nil") - } - if !errors.Is(err, os.ErrNotExist) { - t.Errorf("expected os.ErrNotExist, got: %v", err) - } -} diff --git a/internal/project/project.go b/internal/project/project.go deleted file mode 100644 index 8f036fd..0000000 --- a/internal/project/project.go +++ /dev/null @@ -1,12 +0,0 @@ -package project - -// Project stores durable creative context for a content production workflow. -type Project struct { - ID string - Name string - Platform string - Audience string - Persona string - Memory map[string]string - Constraints []string -} diff --git a/internal/projectx/projectx.go b/internal/projectx/projectx.go deleted file mode 100644 index c4a3750..0000000 --- a/internal/projectx/projectx.go +++ /dev/null @@ -1,427 +0,0 @@ -// Package projectx is openmelon's per-project state — the on-disk -// equivalent of a Claude Code project. -// -// A project's OpenMelon state lives under /.openmelon/. User-facing -// deliverables live in visible project directories such as /outputs/. -// The global registry in userconfig.Projects only holds (id → workdir) -// pointers. -// -// Layout: -// -// /.openmelon/ -// project.json the file this package owns -// characters/ per-character subdirs (registry package) -// references/ per-reference subdirs (registry package) -// materials/ opaque material pool (registry package) -// sessions/ multi-turn session state -// index.jsonl flat search index (search package) -// history.jsonl full conversation log -// -// /outputs/ -// sessions// generated drafts from one run -// artifacts// promoted deliverables -// -// projectx itself owns project.json + the "is this a project root" -// detection. Nothing else. -// -// "projectx" instead of "project" because internal/project already -// exists for the legacy 0.1 workflow loader. The two will eventually -// converge (likely by deleting internal/project once nobody runs the -// declarative workflow path) but for now they coexist. -package projectx - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -// DirName is the per-project state directory. Always ".openmelon". -const DirName = ".openmelon" - -// DefaultOutputDirName is the default visible project directory for user-facing -// generated files. Hidden .openmelon paths are reserved for runtime state. -const DefaultOutputDirName = "outputs" - -// FileName is the per-project config file. Always "project.json". -const FileName = "project.json" - -// Project is the on-disk shape of /.openmelon/project.json. -type Project struct { - // ID is the project slug. Must be kebab-case (lowercase letters, - // digits, hyphens). Stable for the life of the project — used as - // the registry key. - ID string `json:"id"` - - // Name is the human-readable label. Free text. - Name string `json:"name"` - - // Description is one to a few sentences explaining what this project - // is about — "this is my AI commentary content account, posts on X - // 3x/week, target is software engineers". Sent to the LLM as - // context on every run. - Description string `json:"description,omitempty"` - - // Persona is the creator's voice / tone guidance. Sent to the LLM. - Persona string `json:"persona,omitempty"` - - // Constraints is a flat list of "rules of the house" — things the - // agent must not do for this project. e.g. "no clickbait headlines", - // "no medical advice". - Constraints []string `json:"constraints,omitempty"` - - // Defaults override userconfig.Defaults for this project only. - // Empty strings fall back to user defaults. - Defaults Defaults `json:"defaults,omitempty"` - - // Providers holds project-scoped provider connection settings. - // Values here override ~/.openmelon/config.json providers and - // credentials.json/env fallbacks. - Providers map[string]ProviderConfig `json:"providers,omitempty"` - - // Settings collects per-project agent behavior toggles. Edited via - // the /settings TUI panel; surfaced in `openmelon project show`. - Settings Settings `json:"settings,omitempty"` - - // CreatedAt is set by Init at first write and never changed. - CreatedAt time.Time `json:"created_at"` -} - -// BashPermissionMode controls how the bash tool decides whether to -// run a given command. -type BashPermissionMode string - -const ( - // BashModeStrict (default): every bash command needs explicit - // user approval. The judge LLM only flags BLOCK to refuse - // destructive commands; everything else asks the user. - BashModeStrict BashPermissionMode = "strict" - - // BashModeAuto: judge LLM classifies into AUTO / ASK / BLOCK. - // Read-only inspection commands run without asking; ambiguous - // commands prompt; dangerous ones are refused. - BashModeAuto BashPermissionMode = "auto" - - // BashModeTrusted: every bash runs without asking. Equivalent to - // Claude Code's --dangerously-skip-permissions; only enable when - // you trust the model's judgment for the project at hand - // (e.g. throwaway scratchpad, no secrets). - BashModeTrusted BashPermissionMode = "trusted" -) - -// Settings collects per-project agent behavior toggles. -// -// Empty values mean "use the global default in userconfig.Config" or -// the hard-coded fall-back if no global is set. -type Settings struct { - // BashPermissionMode selects the approval gate for the bash tool. - // Empty defaults to BashModeStrict. - BashPermissionMode BashPermissionMode `json:"bash_permission_mode,omitempty"` - - // ReasoningEffort is the model thinking-depth sent with each agent - // request when the provider supports it. Empty defaults to "xhigh" - // for GPT-5-family OpenAI/OpenRouter models and provider default - // elsewhere. - ReasoningEffort string `json:"reasoning_effort,omitempty"` -} - -// EffectiveBashMode returns the mode the runtime should use, applying -// the strict default when no value is configured. -func (s Settings) EffectiveBashMode() BashPermissionMode { - switch s.BashPermissionMode { - case BashModeAuto, BashModeTrusted: - return s.BashPermissionMode - } - return BashModeStrict -} - -// EffectiveReasoningEffort returns the configured model thinking depth. -// Empty means callers should pick a model-aware default. -func (s Settings) EffectiveReasoningEffort() string { - switch strings.ToLower(strings.TrimSpace(s.ReasoningEffort)) { - case "none", "minimal", "low", "medium", "high", "xhigh": - return strings.ToLower(strings.TrimSpace(s.ReasoningEffort)) - default: - return "" - } -} - -// Defaults are per-project overrides for the model + locale knobs. -// Mirror of userconfig.Defaults so projects can pin their own. -type Defaults struct { - LLMProvider string `json:"llm_provider,omitempty"` - LLMModel string `json:"llm_model,omitempty"` - ImageProvider string `json:"image_provider,omitempty"` - ImageModel string `json:"image_model,omitempty"` - VisionModel string `json:"vision_model,omitempty"` - Locale string `json:"locale,omitempty"` -} - -// ProviderConfig mirrors userconfig.ProviderConfig without importing -// userconfig (which would create an import cycle). -type ProviderConfig struct { - APIKey string `json:"api_key,omitempty"` - BaseURL string `json:"base_url,omitempty"` -} - -var slugRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - -// ErrNotAProject is returned by Load when the given workdir has no -// .openmelon/project.json file. -var ErrNotAProject = errors.New("projectx: not an openmelon project (no .openmelon/project.json)") - -// ErrAlreadyInitialized is returned by Init when project.json already -// exists. Callers can decide whether to overwrite via Save. -var ErrAlreadyInitialized = errors.New("projectx: project already initialized") - -// ConfigPath returns /.openmelon/project.json. -func ConfigPath(workdir string) string { - return filepath.Join(workdir, DirName, FileName) -} - -// StateDir returns /.openmelon. -func StateDir(workdir string) string { - return filepath.Join(workdir, DirName) -} - -// OutputDir returns the default visible directory for user-facing outputs. -func OutputDir(workdir string) string { - return filepath.Join(workdir, DefaultOutputDirName) -} - -// SessionOutputDir returns the visible output directory for one generation -// session. The session transcript remains under .openmelon/sessions. -func SessionOutputDir(workdir, sessionID string) string { - sessionID = strings.TrimSpace(sessionID) - if sessionID == "" { - sessionID = "session" - } - return filepath.Join(OutputDir(workdir), "sessions", sessionID) -} - -// ArtifactOutputDir returns the default visible directory for a promoted -// artifact bucket. -func ArtifactOutputDir(workdir, slug, timestamp string) string { - return filepath.Join(OutputDir(workdir), "artifacts", slug, timestamp) -} - -// ResolveOutputDir resolves a user/model-selected output directory inside the -// project. Empty requested uses fallback, then /outputs. Paths under -// .openmelon are rejected because that directory is reserved for internal -// state. -func ResolveOutputDir(workdir, requested, fallback string) (string, error) { - absWorkdir, err := filepath.Abs(workdir) - if err != nil { - return "", err - } - requested = strings.TrimSpace(requested) - out := fallback - if requested != "" { - if filepath.IsAbs(requested) { - out = requested - } else { - out = filepath.Join(absWorkdir, requested) - } - } - if strings.TrimSpace(out) == "" { - out = OutputDir(absWorkdir) - } - absOut, err := filepath.Abs(out) - if err != nil { - return "", err - } - if !pathInside(absWorkdir, absOut) { - return "", fmt.Errorf("projectx: output dir %q escapes project workdir", requested) - } - if pathInside(StateDir(absWorkdir), absOut) { - return "", fmt.Errorf("projectx: output dir %q is inside .openmelon; choose a visible project directory", requested) - } - return absOut, nil -} - -// Init creates a new project at workdir. Errors if one is already -// there — use Save to overwrite an existing project.json. -// -// id and name are required; description/persona/constraints/defaults -// can be edited later via Save. -func Init(workdir, id, name string) (*Project, error) { - if err := ValidateID(id); err != nil { - return nil, err - } - if strings.TrimSpace(name) == "" { - return nil, fmt.Errorf("projectx: name is required") - } - if _, err := os.Stat(ConfigPath(workdir)); err == nil { - return nil, ErrAlreadyInitialized - } - for _, sub := range []string{"characters", "references", "materials", "sessions", "spaces"} { - if err := os.MkdirAll(filepath.Join(StateDir(workdir), sub), 0o755); err != nil { - return nil, fmt.Errorf("projectx: mkdir %s: %w", sub, err) - } - } - if err := os.MkdirAll(OutputDir(workdir), 0o755); err != nil { - return nil, fmt.Errorf("projectx: mkdir outputs: %w", err) - } - p := &Project{ - ID: id, - Name: name, - CreatedAt: time.Now().UTC(), - } - if err := Save(workdir, p); err != nil { - return nil, err - } - if err := EnsureGitignore(workdir); err != nil { - return nil, fmt.Errorf("projectx: write .gitignore: %w", err) - } - return p, nil -} - -// gitignoreContent is the body written to /.openmelon/.gitignore. -// -// Listed entries are scoped to the .openmelon dir (the file lives -// inside it), so "credentials.json" matches .openmelon/credentials.json -// and "sessions/" matches .openmelon/sessions/. Both contain user- -// sensitive material (API keys, conversation transcripts, generated -// drafts possibly drawn from personal photos). -// -// Things deliberately NOT excluded: -// - characters/ + references/ user-curated content; usually wants to commit -// - spaces/ creative continuity state; usually wants to commit -// - materials/ ambiguous; left to the user -// - project.json always commit -const gitignoreContent = `# openmelon — auto-generated. Edit if you want different defaults. -# These paths are relative to this .openmelon/ directory. - -# API keys (never commit). -credentials.json - -# Per-run conversation transcripts + generated images. -sessions/ - -# Legacy hidden outputs. New user-facing outputs go under ../outputs/. -artifacts/ -` - -// EnsureGitignore writes /.openmelon/.gitignore if it doesn't -// already exist. Idempotent: existing files are left alone (so users -// can edit them). -func EnsureGitignore(workdir string) error { - dir := StateDir(workdir) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - path := filepath.Join(dir, ".gitignore") - if _, err := os.Stat(path); err == nil { - return nil // already present, don't clobber - } else if !os.IsNotExist(err) { - return err - } - return os.WriteFile(path, []byte(gitignoreContent), 0o644) -} - -// Load reads the project.json under workdir. Returns ErrNotAProject if -// the file does not exist. -func Load(workdir string) (*Project, error) { - path := ConfigPath(workdir) - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: %s", ErrNotAProject, workdir) - } - return nil, fmt.Errorf("projectx: read %s: %w", path, err) - } - var p Project - if err := json.Unmarshal(b, &p); err != nil { - return nil, fmt.Errorf("projectx: parse %s: %w", path, err) - } - if err := ValidateID(p.ID); err != nil { - return nil, fmt.Errorf("projectx: %s: %w", path, err) - } - return &p, nil -} - -// Save writes project.json. Always overwrites. -func Save(workdir string, p *Project) error { - if err := ValidateID(p.ID); err != nil { - return err - } - if strings.TrimSpace(p.Name) == "" { - return fmt.Errorf("projectx: name is required") - } - if err := os.MkdirAll(StateDir(workdir), 0o755); err != nil { - return fmt.Errorf("projectx: mkdir state: %w", err) - } - b, err := json.MarshalIndent(p, "", " ") - if err != nil { - return fmt.Errorf("projectx: marshal: %w", err) - } - path := ConfigPath(workdir) - tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - if _, err := tmp.Write(append(b, '\n')); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpPath, path) -} - -// Discover walks up from start looking for a .openmelon/project.json. -// Returns the project's workdir (the directory that contains -// .openmelon/), or "" if none is found before the filesystem root. -func Discover(start string) (string, error) { - cur, err := filepath.Abs(start) - if err != nil { - return "", err - } - for { - if _, err := os.Stat(filepath.Join(cur, DirName, FileName)); err == nil { - return cur, nil - } - parent := filepath.Dir(cur) - if parent == cur { - return "", nil - } - cur = parent - } -} - -func pathInside(base, candidate string) bool { - rel, err := filepath.Rel(base, candidate) - if err != nil { - return false - } - return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) -} - -// ValidateID returns nil if id is a valid project slug. -// -// Rules: starts with a lowercase letter, only lowercase letters / -// digits / hyphens, no leading/trailing hyphens, length 2..64. Same -// shape as skillplus tag rules so people don't have to learn two. -func ValidateID(id string) error { - if len(id) < 2 || len(id) > 64 { - return fmt.Errorf("projectx: id %q must be 2..64 chars", id) - } - if !slugRe.MatchString(id) { - return fmt.Errorf("projectx: id %q must be kebab-case (lowercase letter start, then [a-z0-9-])", id) - } - if strings.HasSuffix(id, "-") { - return fmt.Errorf("projectx: id %q must not end with a hyphen", id) - } - if strings.Contains(id, "--") { - return fmt.Errorf("projectx: id %q must not contain consecutive hyphens", id) - } - return nil -} diff --git a/internal/projectx/projectx_test.go b/internal/projectx/projectx_test.go deleted file mode 100644 index 2564dd4..0000000 --- a/internal/projectx/projectx_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package projectx - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestValidateIDAcceptsValidSlugs(t *testing.T) { - for _, id := range []string{"a1", "ai-talks", "fitness", "badminton-club", "x9"} { - if err := ValidateID(id); err != nil { - t.Errorf("ValidateID(%q) unexpected error: %v", id, err) - } - } -} - -func TestValidateIDRejectsBadSlugs(t *testing.T) { - for _, id := range []string{ - "", "a", "AI-talks", "9foo", "-bar", "bar-", "foo--bar", "with space", "with_underscore", - } { - if err := ValidateID(id); err == nil { - t.Errorf("ValidateID(%q) expected error", id) - } - } -} - -func TestInitCreatesProjectAndStateDirs(t *testing.T) { - wd := t.TempDir() - p, err := Init(wd, "ai-talks", "AI Talks") - if err != nil { - t.Fatalf("Init: %v", err) - } - if p.ID != "ai-talks" || p.Name != "AI Talks" { - t.Errorf("project mismatch: %+v", p) - } - if p.CreatedAt.IsZero() { - t.Error("created_at not set") - } - for _, sub := range []string{"characters", "references", "materials", "sessions", "spaces"} { - if _, err := os.Stat(filepath.Join(StateDir(wd), sub)); err != nil { - t.Errorf("expected subdir %s, got: %v", sub, err) - } - } - if _, err := os.Stat(OutputDir(wd)); err != nil { - t.Errorf("expected visible outputs dir, got: %v", err) - } - if _, err := os.Stat(filepath.Join(StateDir(wd), "artifacts")); !os.IsNotExist(err) { - t.Errorf("hidden artifacts dir should not be created by default, stat err=%v", err) - } -} - -func TestInitTwiceIsErrAlreadyInitialized(t *testing.T) { - wd := t.TempDir() - if _, err := Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("Init #1: %v", err) - } - _, err := Init(wd, "ai-talks", "AI Talks") - if !errors.Is(err, ErrAlreadyInitialized) { - t.Errorf("expected ErrAlreadyInitialized, got %v", err) - } -} - -func TestLoadReturnsErrNotAProjectForBareDir(t *testing.T) { - wd := t.TempDir() - _, err := Load(wd) - if !errors.Is(err, ErrNotAProject) { - t.Errorf("expected ErrNotAProject, got %v", err) - } -} - -func TestSaveRoundtrip(t *testing.T) { - wd := t.TempDir() - in, err := Init(wd, "ai-talks", "AI Talks") - if err != nil { - t.Fatalf("Init: %v", err) - } - in.Description = "Daily commentary on AI infra news." - in.Persona = "Skeptical, terse, technical." - in.Constraints = []string{"no clickbait", "no benchmarks without methodology"} - in.Defaults.LLMProvider = "openrouter" - in.Defaults.LLMModel = "x-ai/grok-4" - in.Defaults.ImageProvider = "openrouter" - in.Defaults.ImageModel = "google/gemini-2.5-flash-image" - in.Defaults.Locale = "zh-CN" - in.Settings.BashPermissionMode = BashModeAuto - in.Settings.ReasoningEffort = "xhigh" - if err := Save(wd, in); err != nil { - t.Fatalf("Save: %v", err) - } - out, err := Load(wd) - if err != nil { - t.Fatalf("Load: %v", err) - } - if out.Description != in.Description { - t.Errorf("desc mismatch") - } - if out.Persona != in.Persona { - t.Errorf("persona mismatch") - } - if len(out.Constraints) != 2 || out.Constraints[0] != "no clickbait" { - t.Errorf("constraints mismatch: %v", out.Constraints) - } - if out.Defaults != in.Defaults { - t.Errorf("defaults mismatch: %+v vs %+v", out.Defaults, in.Defaults) - } - if out.Settings != in.Settings { - t.Errorf("settings mismatch: %+v vs %+v", out.Settings, in.Settings) - } -} - -func TestEffectiveReasoningEffort(t *testing.T) { - if got := (Settings{ReasoningEffort: "XHIGH"}).EffectiveReasoningEffort(); got != "xhigh" { - t.Fatalf("xhigh normalized to %q", got) - } - if got := (Settings{ReasoningEffort: "unsupported"}).EffectiveReasoningEffort(); got != "" { - t.Fatalf("unsupported effort = %q, want empty", got) - } -} - -func TestResolveOutputDirRejectsHiddenState(t *testing.T) { - wd := t.TempDir() - got, err := ResolveOutputDir(wd, "", "") - if err != nil { - t.Fatalf("ResolveOutputDir default: %v", err) - } - if got != OutputDir(wd) { - t.Fatalf("default output dir = %q, want %q", got, OutputDir(wd)) - } - if _, err := ResolveOutputDir(wd, ".openmelon/artifacts", ""); err == nil { - t.Fatal("expected .openmelon output dir to be rejected") - } - if _, err := ResolveOutputDir(wd, "../outside", ""); err == nil { - t.Fatal("expected escaping output dir to be rejected") - } -} - -func TestDiscoverFindsProjectRootFromSubdir(t *testing.T) { - wd := t.TempDir() - if _, err := Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("Init: %v", err) - } - deep := filepath.Join(wd, "a", "b", "c") - if err := os.MkdirAll(deep, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - got, err := Discover(deep) - if err != nil { - t.Fatalf("Discover: %v", err) - } - wantAbs, _ := filepath.Abs(wd) - gotAbs, _ := filepath.Abs(got) - if gotAbs != wantAbs { - t.Errorf("Discover: got %q want %q", gotAbs, wantAbs) - } -} - -func TestDiscoverReturnsEmptyWhenNoProject(t *testing.T) { - wd := t.TempDir() - got, err := Discover(wd) - if err != nil { - t.Fatalf("Discover: %v", err) - } - if got != "" { - t.Errorf("expected empty workdir, got %q", got) - } -} - -func TestInitWritesGitignore(t *testing.T) { - wd := t.TempDir() - if _, err := Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("Init: %v", err) - } - body, err := os.ReadFile(filepath.Join(StateDir(wd), ".gitignore")) - if err != nil { - t.Fatalf("read .gitignore: %v", err) - } - for _, want := range []string{"credentials.json", "sessions/"} { - if !strings.Contains(string(body), want) { - t.Errorf(".gitignore missing %q. Body:\n%s", want, body) - } - } -} - -func TestEnsureGitignoreLeavesExistingAlone(t *testing.T) { - wd := t.TempDir() - if _, err := Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("Init: %v", err) - } - custom := []byte("# my custom rules\nfoo/\n") - gi := filepath.Join(StateDir(wd), ".gitignore") - if err := os.WriteFile(gi, custom, 0o644); err != nil { - t.Fatalf("write custom: %v", err) - } - if err := EnsureGitignore(wd); err != nil { - t.Fatalf("EnsureGitignore: %v", err) - } - body, _ := os.ReadFile(gi) - if string(body) != string(custom) { - t.Errorf("EnsureGitignore clobbered user edits. Got:\n%s", body) - } -} - -func TestEnsureGitignoreRetrofitsMissing(t *testing.T) { - wd := t.TempDir() - if _, err := Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("Init: %v", err) - } - gi := filepath.Join(StateDir(wd), ".gitignore") - // Simulate a project from before this feature existed. - if err := os.Remove(gi); err != nil { - t.Fatalf("remove pre-existing: %v", err) - } - if err := EnsureGitignore(wd); err != nil { - t.Fatalf("EnsureGitignore: %v", err) - } - body, err := os.ReadFile(gi) - if err != nil { - t.Fatalf("read after retrofit: %v", err) - } - if !strings.Contains(string(body), "credentials.json") { - t.Errorf("retrofit body missing credentials.json:\n%s", body) - } -} diff --git a/internal/provenance/provenance.go b/internal/provenance/provenance.go deleted file mode 100644 index 4e9a96f..0000000 --- a/internal/provenance/provenance.go +++ /dev/null @@ -1,16 +0,0 @@ -package provenance - -// Record describes how an artifact was produced. -type Record struct { - ArtifactID string `json:"artifact_id"` - ProjectID string `json:"project_id"` - WorkflowID string `json:"workflow_id"` - Stage string `json:"stage"` - SkillPackage string `json:"skill_package"` - CompiledTarget string `json:"compiled_target"` - Model string `json:"model"` - PromptHash string `json:"prompt_hash"` - GenerationParams map[string]string `json:"generation_params,omitempty"` - EvaluationResult string `json:"evaluation_result,omitempty"` - Timestamp string `json:"timestamp"` -} diff --git a/internal/provenance/writer.go b/internal/provenance/writer.go deleted file mode 100644 index e7820d4..0000000 --- a/internal/provenance/writer.go +++ /dev/null @@ -1,39 +0,0 @@ -package provenance - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -// AppendRecord serializes rec to JSON and appends it as a single line to the file at path. -// The directory is created automatically if it does not exist. -// The file is fsynced after writing to guard against data loss on crash. -func AppendRecord(path string, rec *Record) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("provenance.AppendRecord: mkdir: %w", err) - } - - data, err := json.Marshal(rec) - if err != nil { - return fmt.Errorf("provenance.AppendRecord: marshal: %w", err) - } - - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) - if err != nil { - return fmt.Errorf("provenance.AppendRecord: open %q: %w", path, err) - } - defer f.Close() - - if _, err := f.Write(data); err != nil { - return fmt.Errorf("provenance.AppendRecord: write: %w", err) - } - if _, err := f.WriteString("\n"); err != nil { - return fmt.Errorf("provenance.AppendRecord: write newline: %w", err) - } - if err := f.Sync(); err != nil { - return fmt.Errorf("provenance.AppendRecord: sync: %w", err) - } - return nil -} diff --git a/internal/provenance/writer_test.go b/internal/provenance/writer_test.go deleted file mode 100644 index 62fe8d5..0000000 --- a/internal/provenance/writer_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package provenance - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestAppendRecord_createAndAppend(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "provenance.jsonl") - - rec1 := &Record{ - ArtifactID: "id1", - ProjectID: "proj", - Stage: "stage1", - Timestamp: "2024-01-01T00:00:00Z", - } - rec2 := &Record{ - ArtifactID: "id2", - ProjectID: "proj", - Stage: "stage2", - Timestamp: "2024-01-01T00:01:00Z", - } - - if err := AppendRecord(path, rec1); err != nil { - t.Fatalf("AppendRecord(rec1) error: %v", err) - } - if err := AppendRecord(path, rec2); err != nil { - t.Fatalf("AppendRecord(rec2) error: %v", err) - } - - f, err := os.Open(path) - if err != nil { - t.Fatal(err) - } - defer f.Close() - - var records []Record - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - var r Record - if err := json.Unmarshal([]byte(line), &r); err != nil { - t.Fatalf("failed to unmarshal line %q: %v", line, err) - } - records = append(records, r) - } - - if len(records) != 2 { - t.Fatalf("expected 2 records, got %d", len(records)) - } - if records[0].ArtifactID != "id1" { - t.Errorf("records[0].ArtifactID = %q, want %q", records[0].ArtifactID, "id1") - } - if records[1].ArtifactID != "id2" { - t.Errorf("records[1].ArtifactID = %q, want %q", records[1].ArtifactID, "id2") - } -} - -func TestAppendRecord_createsDirIfMissing(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "nested", "deep", "provenance.jsonl") - rec := &Record{ArtifactID: "x"} - - if err := AppendRecord(path, rec); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if _, err := os.Stat(path); err != nil { - t.Errorf("file not created: %v", err) - } -} diff --git a/internal/registry/registry.go b/internal/registry/registry.go deleted file mode 100644 index c205c61..0000000 --- a/internal/registry/registry.go +++ /dev/null @@ -1,514 +0,0 @@ -// Package registry is the on-disk store for openmelon's project-scoped -// content libraries: characters, references, and materials. -// -// All three share the same shape: -// - a directory under /.openmelon/// -// - a JSON metadata file (character.json / reference.json / material.json) -// - a `.search` file with description + tags (mirror of skillplus' format) -// - one or more attached image files -// -// "Kind" enumerates the three. They differ only in metadata semantics -// (a character has age/role/style, a reference has a scene description, a -// material has just a hash + source) — operations are uniform. -// -// The package is intentionally narrow: list / get / add / remove. Search -// (cross-kind grep) lives in package search; vision auto-describe lives -// in package runtime. Both consume registry's outputs. -package registry - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -// Kind is a content library: characters / references / materials. -type Kind string - -const ( - KindCharacter Kind = "character" - KindReference Kind = "reference" - KindMaterial Kind = "material" -) - -// dirFor returns the disk subdirectory for a given kind. -func dirFor(kind Kind) string { - switch kind { - case KindCharacter: - return "characters" - case KindReference: - return "references" - case KindMaterial: - return "materials" - } - return "" -} - -// metaFileFor returns the metadata filename for a given kind. -func metaFileFor(kind Kind) string { - switch kind { - case KindCharacter: - return "character.json" - case KindReference: - return "reference.json" - case KindMaterial: - return "material.json" - } - return "" -} - -// SearchFileName is the file holding description + tags. Same name across -// all three kinds, so search can grep them uniformly. -const SearchFileName = ".search" - -// Errors. -var ( - ErrInvalidKind = errors.New("registry: unknown kind") - ErrNotFound = errors.New("registry: not found") - ErrAlreadyExists = errors.New("registry: already exists") -) - -var slugRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - -// ValidateSlug applies the same kebab-case rule as projectx + skillplus. -func ValidateSlug(slug string) error { - if len(slug) < 2 || len(slug) > 64 { - return fmt.Errorf("registry: slug %q must be 2..64 chars", slug) - } - if !slugRe.MatchString(slug) { - return fmt.Errorf("registry: slug %q must be kebab-case ([a-z][a-z0-9-]*)", slug) - } - if strings.HasSuffix(slug, "-") || strings.Contains(slug, "--") { - return fmt.Errorf("registry: slug %q must not have trailing or doubled hyphens", slug) - } - return nil -} - -// Item is the unified shape returned by List / Get. Per-kind extras -// live in the Extra map (round-tripped through metadata.json). -type Item struct { - Kind Kind `json:"kind"` - Slug string `json:"slug"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags,omitempty"` - Images []string `json:"images,omitempty"` // basenames inside the item dir - Extra map[string]string `json:"extra,omitempty"` // kind-specific scalar metadata - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// itemDir is /.openmelon///. -func itemDir(workdir string, kind Kind, slug string) string { - return filepath.Join(projectx.StateDir(workdir), dirFor(kind), slug) -} - -// itemMetaPath is /.json. -func itemMetaPath(workdir string, kind Kind, slug string) string { - return filepath.Join(itemDir(workdir, kind, slug), metaFileFor(kind)) -} - -// itemSearchPath is /.search. -func itemSearchPath(workdir string, kind Kind, slug string) string { - return filepath.Join(itemDir(workdir, kind, slug), SearchFileName) -} - -// List returns all items of a given kind in slug order. -func List(workdir string, kind Kind) ([]*Item, error) { - if dirFor(kind) == "" { - return nil, fmt.Errorf("%w: %q", ErrInvalidKind, kind) - } - root := filepath.Join(projectx.StateDir(workdir), dirFor(kind)) - entries, err := os.ReadDir(root) - if err != nil { - if os.IsNotExist(err) { - return []*Item{}, nil - } - return nil, fmt.Errorf("registry: list %s: %w", kind, err) - } - out := make([]*Item, 0, len(entries)) - for _, e := range entries { - if !e.IsDir() { - continue - } - item, err := Get(workdir, kind, e.Name()) - if err != nil { - // Skip half-written items but don't fail the whole list. - continue - } - out = append(out, item) - } - sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug }) - return out, nil -} - -// Get reads a single item. -func Get(workdir string, kind Kind, slug string) (*Item, error) { - if dirFor(kind) == "" { - return nil, fmt.Errorf("%w: %q", ErrInvalidKind, kind) - } - if err := ValidateSlug(slug); err != nil { - return nil, err - } - metaPath := itemMetaPath(workdir, kind, slug) - b, err := os.ReadFile(metaPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: %s/%s", ErrNotFound, kind, slug) - } - return nil, fmt.Errorf("registry: read %s: %w", metaPath, err) - } - var item Item - if err := json.Unmarshal(b, &item); err != nil { - return nil, fmt.Errorf("registry: parse %s: %w", metaPath, err) - } - // Re-fill from .search (source of truth for description + tags so - // search package and registry agree on a single bytes-on-disk view). - desc, tags, err := readSearch(itemSearchPath(workdir, kind, slug)) - if err == nil { - item.Description = desc - item.Tags = tags - } - // Re-list images on disk. - dir := itemDir(workdir, kind, slug) - if entries, err := os.ReadDir(dir); err == nil { - images := []string{} - for _, e := range entries { - if e.IsDir() { - continue - } - n := e.Name() - if isImage(n) { - images = append(images, n) - } - } - sort.Strings(images) - item.Images = images - } - return &item, nil -} - -// AddOptions describes a new item being added to the registry. -type AddOptions struct { - Kind Kind - Slug string - Name string - - // Description is one to a few sentences describing the item. Kept - // together with Tags in .search so search can grep them. - Description string - Tags []string - - // Extra is per-kind scalar metadata (e.g. character age, reference - // scene type). Stored verbatim in the item's metadata JSON. - Extra map[string]string - - // ImagePath, if non-empty, is copied into the item directory as - // "image-001" (or whatever ImageName supplies). Multiple Add - // calls with --append-image can stack additional images. - ImagePath string - // ImageName overrides the destination basename (without extension). - ImageName string - - // AllowExists, when true, makes Add idempotent — re-adding the same - // slug merges in new metadata + appends the image instead of - // returning ErrAlreadyExists. Used by `... add --update`. - AllowExists bool -} - -// Add creates or updates an item. -// -// Returns ErrAlreadyExists if the item already exists and AllowExists is -// false. -func Add(workdir string, opts AddOptions) (*Item, error) { - if dirFor(opts.Kind) == "" { - return nil, fmt.Errorf("%w: %q", ErrInvalidKind, opts.Kind) - } - if err := ValidateSlug(opts.Slug); err != nil { - return nil, err - } - if strings.TrimSpace(opts.Name) == "" { - opts.Name = opts.Slug - } - dir := itemDir(workdir, opts.Kind, opts.Slug) - metaPath := itemMetaPath(workdir, opts.Kind, opts.Slug) - - now := time.Now().UTC() - var item Item - if existing, err := os.ReadFile(metaPath); err == nil { - if !opts.AllowExists { - return nil, fmt.Errorf("%w: %s/%s", ErrAlreadyExists, opts.Kind, opts.Slug) - } - if err := json.Unmarshal(existing, &item); err != nil { - return nil, fmt.Errorf("registry: parse existing %s: %w", metaPath, err) - } - } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("registry: stat %s: %w", metaPath, err) - } else { - item = Item{Kind: opts.Kind, Slug: opts.Slug, CreatedAt: now} - } - - if opts.Name != "" { - item.Name = opts.Name - } - if opts.Description != "" { - item.Description = opts.Description - } - if len(opts.Tags) > 0 { - item.Tags = opts.Tags - } - if len(opts.Extra) > 0 { - if item.Extra == nil { - item.Extra = map[string]string{} - } - for k, v := range opts.Extra { - item.Extra[k] = v - } - } - item.UpdatedAt = now - - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("registry: mkdir %s: %w", dir, err) - } - - // Copy image if supplied. - if opts.ImagePath != "" { - if err := copyImageInto(dir, opts.ImagePath, opts.ImageName); err != nil { - return nil, err - } - } - - // Write .search (source of truth for description + tags). - if err := writeSearch(itemSearchPath(workdir, opts.Kind, opts.Slug), item.Description, item.Tags); err != nil { - return nil, err - } - - // Persist metadata JSON. Strip Description+Tags+Images: those live - // in .search / on disk respectively, so we don't store them twice. - persisted := item - persisted.Description = "" - persisted.Tags = nil - persisted.Images = nil - b, err := json.MarshalIndent(persisted, "", " ") - if err != nil { - return nil, fmt.Errorf("registry: marshal: %w", err) - } - if err := os.WriteFile(metaPath, append(b, '\n'), 0o644); err != nil { - return nil, fmt.Errorf("registry: write %s: %w", metaPath, err) - } - - return Get(workdir, opts.Kind, opts.Slug) -} - -// SetSearch updates only description + tags for an item. Used by the -// vision auto-describe path so it doesn't have to re-read all of meta. -func SetSearch(workdir string, kind Kind, slug, description string, tags []string) error { - if dirFor(kind) == "" { - return fmt.Errorf("%w: %q", ErrInvalidKind, kind) - } - if err := ValidateSlug(slug); err != nil { - return err - } - if _, err := os.Stat(itemMetaPath(workdir, kind, slug)); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("%w: %s/%s", ErrNotFound, kind, slug) - } - return err - } - if err := writeSearch(itemSearchPath(workdir, kind, slug), description, tags); err != nil { - return err - } - // Bump UpdatedAt. - item, err := Get(workdir, kind, slug) - if err != nil { - return err - } - item.UpdatedAt = time.Now().UTC() - persisted := *item - persisted.Description = "" - persisted.Tags = nil - persisted.Images = nil - b, err := json.MarshalIndent(persisted, "", " ") - if err != nil { - return err - } - return os.WriteFile(itemMetaPath(workdir, kind, slug), append(b, '\n'), 0o644) -} - -// Remove deletes the item directory and everything in it. -func Remove(workdir string, kind Kind, slug string) error { - if dirFor(kind) == "" { - return fmt.Errorf("%w: %q", ErrInvalidKind, kind) - } - if err := ValidateSlug(slug); err != nil { - return err - } - dir := itemDir(workdir, kind, slug) - if _, err := os.Stat(dir); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("%w: %s/%s", ErrNotFound, kind, slug) - } - return err - } - return os.RemoveAll(dir) -} - -// AddMaterial is a thin wrapper for the material-pool flow, where the -// slug is the sha256 of the file (so duplicates collapse). -// -// Returns the resulting item; if a material with the same hash already -// exists, the call is a no-op and the existing item is returned. -func AddMaterial(workdir, srcPath string, tags []string) (*Item, error) { - hash, err := fileSHA256(srcPath) - if err != nil { - return nil, err - } - // Prefix with "m-" so the hex hash satisfies ValidateSlug (which - // requires a leading [a-z]). 16 hex chars = 64 bits of entropy, - // which is plenty for a per-project pool. - slug := "m-" + hash[:16] - return Add(workdir, AddOptions{ - Kind: KindMaterial, - Slug: slug, - Name: slug, - Tags: tags, - Extra: map[string]string{"sha256": hash}, - ImagePath: srcPath, - ImageName: "image", - AllowExists: true, - }) -} - -// --- helpers --- - -// .search is a tiny line-oriented text format. We control both ends so -// avoid a YAML parser dep. Format: -// -// description: -// tags: tag-a, tag-b, tag-c -// -// readSearch returns ("", nil, nil) when the file is missing. -func readSearch(path string) (string, []string, error) { - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil, nil - } - return "", nil, err - } - desc := "" - var tags []string - for _, raw := range strings.Split(string(b), "\n") { - line := strings.TrimSpace(raw) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, val, ok := strings.Cut(line, ":") - if !ok { - continue - } - val = strings.TrimSpace(val) - switch strings.TrimSpace(key) { - case "description": - desc = val - case "tags": - for _, t := range strings.Split(val, ",") { - t = strings.TrimSpace(t) - if t != "" { - tags = append(tags, t) - } - } - } - } - return desc, tags, nil -} - -func writeSearch(path, desc string, tags []string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - var b strings.Builder - b.WriteString("description: ") - // Collapse newlines so the format stays single-line per field. - b.WriteString(strings.ReplaceAll(strings.TrimSpace(desc), "\n", " ")) - b.WriteString("\n") - if len(tags) > 0 { - b.WriteString("tags: ") - b.WriteString(strings.Join(tags, ", ")) - b.WriteString("\n") - } - return os.WriteFile(path, []byte(b.String()), 0o644) -} - -// copyImageInto copies src into dir. If destBaseName is "", the src -// basename is preserved; otherwise destBaseName + src extension is used. -// If a file with the chosen name already exists, a numeric suffix is -// appended (-2, -3, ...). -func copyImageInto(dir, src, destBaseName string) error { - srcBase := filepath.Base(src) - ext := filepath.Ext(srcBase) - if !isImageExt(ext) { - return fmt.Errorf("registry: %q is not an image (ext %q)", src, ext) - } - base := destBaseName - if base == "" { - base = strings.TrimSuffix(srcBase, ext) - } - candidate := filepath.Join(dir, base+ext) - if _, err := os.Stat(candidate); err == nil { - for i := 2; ; i++ { - candidate = filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext)) - if _, err := os.Stat(candidate); os.IsNotExist(err) { - break - } - } - } - in, err := os.Open(src) - if err != nil { - return fmt.Errorf("registry: open %s: %w", src, err) - } - defer in.Close() - out, err := os.Create(candidate) - if err != nil { - return fmt.Errorf("registry: create %s: %w", candidate, err) - } - defer out.Close() - if _, err := io.Copy(out, in); err != nil { - return fmt.Errorf("registry: copy %s -> %s: %w", src, candidate, err) - } - return nil -} - -func fileSHA256(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -func isImage(name string) bool { return isImageExt(filepath.Ext(name)) } - -func isImageExt(ext string) bool { - switch strings.ToLower(ext) { - case ".png", ".jpg", ".jpeg", ".webp", ".gif": - return true - } - return false -} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go deleted file mode 100644 index e0aa6d5..0000000 --- a/internal/registry/registry_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package registry - -import ( - "errors" - "os" - "path/filepath" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -// initProject sets up a fresh project under a tmpdir and returns the workdir. -func initProject(t *testing.T) string { - t.Helper() - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - return wd -} - -// writePNG writes a minimal valid PNG header to path. The file is small -// but real enough that registry can copy it and read its SHA256. -func writePNG(t *testing.T, path string) { - t.Helper() - // PNG signature + IHDR + IDAT + IEND for a 1x1 RGBA. The bytes - // below are not strictly a decodable image but registry only cares - // about extension + bytes copy. - data := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D} - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatalf("write png: %v", err) - } -} - -func TestAddCharacter(t *testing.T) { - wd := initProject(t) - src := filepath.Join(t.TempDir(), "lao-wang-portrait.png") - writePNG(t, src) - - item, err := Add(wd, AddOptions{ - Kind: KindCharacter, - Slug: "lao-wang", - Name: "Lao Wang", - Description: "Mid-50s street vendor with a quiet smile.", - Tags: []string{"character", "vendor", "elder"}, - Extra: map[string]string{"role": "host"}, - ImagePath: src, - ImageName: "portrait", - }) - if err != nil { - t.Fatalf("Add: %v", err) - } - if item.Slug != "lao-wang" || item.Name != "Lao Wang" { - t.Errorf("item mismatch: %+v", item) - } - if len(item.Images) != 1 || item.Images[0] != "portrait.png" { - t.Errorf("images: %v", item.Images) - } - if got := item.Extra["role"]; got != "host" { - t.Errorf("extra.role: got %q want host", got) - } - if item.Description != "Mid-50s street vendor with a quiet smile." { - t.Errorf("description not persisted: %q", item.Description) - } - if len(item.Tags) != 3 { - t.Errorf("tags: %v", item.Tags) - } -} - -func TestAddTwiceWithoutAllowExistsErrors(t *testing.T) { - wd := initProject(t) - if _, err := Add(wd, AddOptions{Kind: KindCharacter, Slug: "lao-wang", Name: "Lao Wang"}); err != nil { - t.Fatalf("Add #1: %v", err) - } - _, err := Add(wd, AddOptions{Kind: KindCharacter, Slug: "lao-wang", Name: "Lao Wang"}) - if !errors.Is(err, ErrAlreadyExists) { - t.Errorf("expected ErrAlreadyExists, got %v", err) - } -} - -func TestAddWithAllowExistsMergesAndAppendsImage(t *testing.T) { - wd := initProject(t) - src1 := filepath.Join(t.TempDir(), "p1.png") - src2 := filepath.Join(t.TempDir(), "p2.png") - writePNG(t, src1) - writePNG(t, src2) - - if _, err := Add(wd, AddOptions{ - Kind: KindCharacter, Slug: "lao-wang", Name: "Lao Wang", - ImagePath: src1, ImageName: "portrait", - }); err != nil { - t.Fatalf("Add #1: %v", err) - } - updated, err := Add(wd, AddOptions{ - Kind: KindCharacter, Slug: "lao-wang", Description: "Updated bio.", - Tags: []string{"vendor"}, ImagePath: src2, ImageName: "portrait", - AllowExists: true, - }) - if err != nil { - t.Fatalf("Add #2 with AllowExists: %v", err) - } - if updated.Description != "Updated bio." { - t.Errorf("desc not merged: %q", updated.Description) - } - // Same destination basename → second copy gets a -2 suffix. - if len(updated.Images) != 2 { - t.Errorf("expected 2 images, got %v", updated.Images) - } -} - -func TestListReturnsItemsInSlugOrder(t *testing.T) { - wd := initProject(t) - for _, slug := range []string{"zoe", "alice", "bob"} { - if _, err := Add(wd, AddOptions{Kind: KindCharacter, Slug: slug, Name: slug}); err != nil { - t.Fatalf("Add %s: %v", slug, err) - } - } - items, err := List(wd, KindCharacter) - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 3 { - t.Fatalf("len: %d", len(items)) - } - want := []string{"alice", "bob", "zoe"} - for i, s := range want { - if items[i].Slug != s { - t.Errorf("[%d]: %q want %q", i, items[i].Slug, s) - } - } -} - -func TestGetReadsSearchFile(t *testing.T) { - wd := initProject(t) - if _, err := Add(wd, AddOptions{ - Kind: KindReference, Slug: "kitchen-night", - Description: "Warm-tone neon kitchen at 22:00, steam from a wok.", - Tags: []string{"scene", "kitchen", "night"}, - }); err != nil { - t.Fatalf("Add: %v", err) - } - item, err := Get(wd, KindReference, "kitchen-night") - if err != nil { - t.Fatalf("Get: %v", err) - } - if item.Description != "Warm-tone neon kitchen at 22:00, steam from a wok." { - t.Errorf("desc: %q", item.Description) - } - if len(item.Tags) != 3 || item.Tags[0] != "scene" { - t.Errorf("tags: %v", item.Tags) - } -} - -func TestSetSearchUpdatesOnlyDescriptionAndTags(t *testing.T) { - wd := initProject(t) - if _, err := Add(wd, AddOptions{ - Kind: KindCharacter, Slug: "lao-wang", Name: "Lao Wang", - Description: "Old.", Tags: []string{"v1"}, - Extra: map[string]string{"role": "host"}, - }); err != nil { - t.Fatalf("Add: %v", err) - } - if err := SetSearch(wd, KindCharacter, "lao-wang", "New.", []string{"v2"}); err != nil { - t.Fatalf("SetSearch: %v", err) - } - item, err := Get(wd, KindCharacter, "lao-wang") - if err != nil { - t.Fatalf("Get: %v", err) - } - if item.Description != "New." { - t.Errorf("desc: %q", item.Description) - } - if len(item.Tags) != 1 || item.Tags[0] != "v2" { - t.Errorf("tags: %v", item.Tags) - } - if item.Extra["role"] != "host" { - t.Errorf("extra clobbered: %v", item.Extra) - } -} - -func TestRemoveDeletesItemDir(t *testing.T) { - wd := initProject(t) - if _, err := Add(wd, AddOptions{Kind: KindCharacter, Slug: "lao-wang", Name: "Lao Wang"}); err != nil { - t.Fatalf("Add: %v", err) - } - if err := Remove(wd, KindCharacter, "lao-wang"); err != nil { - t.Fatalf("Remove: %v", err) - } - _, err := Get(wd, KindCharacter, "lao-wang") - if !errors.Is(err, ErrNotFound) { - t.Errorf("expected ErrNotFound, got %v", err) - } -} - -func TestAddMaterialIsHashAddressed(t *testing.T) { - wd := initProject(t) - src := filepath.Join(t.TempDir(), "raw.png") - writePNG(t, src) - first, err := AddMaterial(wd, src, []string{"raw"}) - if err != nil { - t.Fatalf("AddMaterial #1: %v", err) - } - second, err := AddMaterial(wd, src, []string{"raw"}) - if err != nil { - t.Fatalf("AddMaterial #2: %v", err) - } - if first.Slug != second.Slug { - t.Errorf("hash collision missed: %q vs %q", first.Slug, second.Slug) - } - items, err := List(wd, KindMaterial) - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 1 { - t.Errorf("expected 1 deduped material, got %d", len(items)) - } -} - -func TestValidateSlug(t *testing.T) { - for _, ok := range []string{"a1", "lao-wang", "kitchen-night-001"} { - if err := ValidateSlug(ok); err != nil { - t.Errorf("ValidateSlug(%q) unexpected error: %v", ok, err) - } - } - for _, bad := range []string{"", "a", "AI", "9foo", "-x", "x-", "x--y", "with space"} { - if err := ValidateSlug(bad); err == nil { - t.Errorf("ValidateSlug(%q) expected error", bad) - } - } -} diff --git a/internal/repl/markdown.go b/internal/repl/markdown.go deleted file mode 100644 index 9a1960e..0000000 --- a/internal/repl/markdown.go +++ /dev/null @@ -1,228 +0,0 @@ -package repl - -import ( - "regexp" - "strings" - "unicode" -) - -var ( - replOrderedListRe = regexp.MustCompile(`^(\s*)(\d+)[.)]\s+(.*)$`) - replLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) -) - -func renderMarkdownWithWidth(src string, width int) string { - src = strings.ReplaceAll(src, "\r\n", "\n") - lines := strings.Split(src, "\n") - - var b strings.Builder - inFence := false - fenceLang := "" - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "```") { - if inFence { - inFence = false - fenceLang = "" - } else { - inFence = true - fenceLang = strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) - if fenceLang != "" { - b.WriteString(mutedStyle.Render(" " + fenceLang)) - b.WriteByte('\n') - } - } - continue - } - - if inFence { - b.WriteString(replToolSummaryStyle.Render(" " + line)) - if i < len(lines)-1 { - b.WriteByte('\n') - } - continue - } - - if trimmed == "" { - b.WriteByte('\n') - continue - } - - switch { - case isMarkdownHeading(trimmed): - _, text := splitMarkdownHeading(trimmed) - b.WriteString(markdownHeadingStyle.Render(renderMarkdownInline(text))) - case isMarkdownRule(trimmed): - b.WriteString(dividerStyle.Render(markdownRuleLine(width))) - case isMarkdownTableDelimiter(trimmed): - continue - case isMarkdownTableRow(trimmed): - b.WriteString(renderMarkdownTableRow(trimmed)) - case strings.HasPrefix(trimmed, ">"): - text := strings.TrimSpace(strings.TrimLeft(trimmed, ">")) - b.WriteString(mutedStyle.Render("> " + renderMarkdownInline(text))) - case isMarkdownUnorderedList(trimmed): - text := strings.TrimSpace(trimmed[1:]) - b.WriteString(" " + mutedStyle.Render("- ") + renderMarkdownInline(text)) - case replOrderedListRe.MatchString(line): - m := replOrderedListRe.FindStringSubmatch(line) - text := strings.TrimSpace(m[3]) - b.WriteString(" " + mutedStyle.Render(m[2]+". ") + renderMarkdownInline(text)) - default: - b.WriteString(renderMarkdownInline(line)) - } - - if i < len(lines)-1 { - b.WriteByte('\n') - } - } - - return strings.TrimRight(b.String(), "\n") -} - -func markdownRuleLine(width int) string { - if width <= 0 || width > 80 { - width = 40 - } - if width < 8 { - width = 8 - } - return strings.Repeat("─", width) -} - -func isMarkdownHeading(line string) bool { - if !strings.HasPrefix(line, "#") { - return false - } - n := 0 - for n < len(line) && line[n] == '#' { - n++ - } - return n > 0 && n <= 6 && n < len(line) && unicode.IsSpace(rune(line[n])) -} - -func splitMarkdownHeading(line string) (int, string) { - n := 0 - for n < len(line) && line[n] == '#' { - n++ - } - return n, strings.TrimSpace(line[n:]) -} - -func isMarkdownRule(line string) bool { - if len(line) < 3 { - return false - } - for _, r := range line { - if r != '-' && r != '*' && r != '_' { - return false - } - } - return true -} - -func isMarkdownUnorderedList(line string) bool { - if len(line) < 2 { - return false - } - switch line[0] { - case '-', '*', '+': - return unicode.IsSpace(rune(line[1])) - default: - return false - } -} - -func isMarkdownTableRow(line string) bool { - return strings.HasPrefix(line, "|") && strings.HasSuffix(line, "|") && strings.Count(line, "|") >= 2 -} - -func isMarkdownTableDelimiter(line string) bool { - if !isMarkdownTableRow(line) { - return false - } - for _, cell := range strings.Split(strings.Trim(line, "|"), "|") { - cell = strings.TrimSpace(cell) - if cell == "" { - return false - } - cell = strings.Trim(cell, ":") - if len(cell) < 3 { - return false - } - for _, r := range cell { - if r != '-' { - return false - } - } - } - return true -} - -func renderMarkdownTableRow(line string) string { - parts := strings.Split(strings.Trim(line, "|"), "|") - for i := range parts { - parts[i] = renderMarkdownInline(strings.TrimSpace(parts[i])) - } - return strings.Join(parts, mutedStyle.Render(" | ")) -} - -func renderMarkdownInline(s string) string { - s = renderMarkdownLinks(s) - s = renderMarkdownDelimited(s, "`", func(v string) string { - return replToolSummaryStyle.Render(v) - }) - s = renderMarkdownDelimited(s, "**", func(v string) string { - return markdownBoldStyle.Render(v) - }) - s = renderMarkdownDelimited(s, "__", func(v string) string { - return markdownBoldStyle.Render(v) - }) - return s -} - -func renderMarkdownLinks(s string) string { - return replLinkRe.ReplaceAllStringFunc(s, func(match string) string { - parts := replLinkRe.FindStringSubmatch(match) - if len(parts) != 3 { - return match - } - label := strings.TrimSpace(parts[1]) - url := strings.TrimSpace(parts[2]) - if label == "" || url == "" { - return match - } - return markdownLinkStyle.Render(label) + mutedStyle.Render(" ("+url+")") - }) -} - -func renderMarkdownDelimited(s, delim string, render func(string) string) string { - if delim == "" { - return s - } - var b strings.Builder - for { - start := strings.Index(s, delim) - if start < 0 { - b.WriteString(s) - break - } - end := strings.Index(s[start+len(delim):], delim) - if end < 0 { - b.WriteString(s) - break - } - end += start + len(delim) - inner := s[start+len(delim) : end] - b.WriteString(s[:start]) - if strings.TrimSpace(inner) == "" { - b.WriteString(delim + inner + delim) - } else { - b.WriteString(render(inner)) - } - s = s[end+len(delim):] - } - return b.String() -} diff --git a/internal/repl/prompt.go b/internal/repl/prompt.go deleted file mode 100644 index 5f42c1a..0000000 --- a/internal/repl/prompt.go +++ /dev/null @@ -1,428 +0,0 @@ -package repl - -import ( - "context" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/charmbracelet/bubbles/cursor" - "github.com/charmbracelet/bubbles/textarea" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "golang.org/x/term" -) - -type promptOutcome int - -const ( - promptNone promptOutcome = iota - promptSubmit - promptCancel - promptExit -) - -type promptResult struct { - outcome promptOutcome - text string -} - -type promptConfig struct { - In io.Reader - Out io.Writer - History []string - ActiveSkill string - Commands []slashCommand -} - -type promptModel struct { - textarea textarea.Model - - width int - height int - - history []string - historyCursor int - historyDraft string - - activeSkill string - commands []slashCommand - - paletteVisible bool - paletteCursor int - - warning string - quitArmedUntil time.Time - - outcome promptOutcome - text string -} - -func newPromptModel(cfg promptConfig) *promptModel { - ta := textarea.New() - ta.Placeholder = "Ask OpenMelon" - ta.Prompt = "› " - ta.CharLimit = 0 - ta.MaxHeight = 10 - ta.ShowLineNumbers = false - ta.FocusedStyle.Base = lipgloss.NewStyle() - ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.FocusedStyle.Prompt = promptArrowStyle - ta.FocusedStyle.Placeholder = mutedStyle - ta.BlurredStyle.Base = lipgloss.NewStyle() - ta.BlurredStyle.CursorLine = lipgloss.NewStyle() - ta.BlurredStyle.Prompt = promptArrowStyle - ta.BlurredStyle.Placeholder = mutedStyle - ta.Cursor.SetMode(cursor.CursorStatic) - ta.Focus() - - width := 80 - if f, ok := cfg.Out.(*os.File); ok && term.IsTerminal(int(f.Fd())) { - if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 { - width = w - } - } - ta.SetWidth(width) - ta.SetHeight(1) - - m := &promptModel{ - textarea: ta, - width: width, - history: append([]string(nil), cfg.History...), - historyCursor: -1, - activeSkill: cfg.ActiveSkill, - commands: cfg.Commands, - } - m.recomputeInputSize() - return m -} - -func readInteractivePrompt(ctx context.Context, cfg promptConfig) (promptResult, error) { - m := newPromptModel(cfg) - opts := []tea.ProgramOption{tea.WithContext(ctx)} - if cfg.In != nil { - opts = append(opts, tea.WithInput(cfg.In)) - } - if cfg.Out != nil { - opts = append(opts, tea.WithOutput(cfg.Out)) - } - prog := tea.NewProgram(m, opts...) - finalModel, err := prog.Run() - if err != nil { - return promptResult{}, err - } - if pm, ok := finalModel.(*promptModel); ok { - return promptResult{outcome: pm.outcome, text: pm.text}, nil - } - return promptResult{outcome: promptExit}, nil -} - -func (m *promptModel) Init() tea.Cmd { - return nil -} - -func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.recomputeInputSize() - return m, nil - case tea.KeyMsg: - if cmd, handled := m.handleKey(msg); handled { - return m, cmd - } - } - - var cmd tea.Cmd - m.textarea, cmd = m.textarea.Update(msg) - m.updatePalette() - m.recomputeInputSize() - return m, cmd -} - -func (m *promptModel) handleKey(msg tea.KeyMsg) (tea.Cmd, bool) { - switch msg.String() { - case "ctrl+c": - return m.handleCtrlC(), true - case "ctrl+d": - if strings.TrimSpace(m.textarea.Value()) == "" { - m.outcome = promptExit - return tea.Quit, true - } - case "esc": - if m.paletteVisible { - m.paletteVisible = false - m.paletteCursor = 0 - return nil, true - } - if strings.TrimSpace(m.textarea.Value()) != "" { - m.textarea.Reset() - m.resetHistoryBrowse() - m.warning = "input cleared" - m.updatePalette() - return nil, true - } - m.warning = "" - return nil, true - case "enter": - return m.submit(), true - case "ctrl+j", "shift+enter", "alt+enter": - m.textarea.InsertString("\n") - m.resetHistoryBrowse() - m.warning = "" - m.paletteVisible = false - m.recomputeInputSize() - return nil, true - case "tab": - if m.paletteVisible { - m.completeSlashCommand() - return nil, true - } - case "up": - if m.paletteVisible { - m.movePalette(-1) - return nil, true - } - if m.canBrowseHistory() { - m.browseHistory(-1) - return nil, true - } - case "down": - if m.paletteVisible { - m.movePalette(1) - return nil, true - } - if m.historyCursor >= 0 { - m.browseHistory(1) - return nil, true - } - } - return nil, false -} - -func (m *promptModel) handleCtrlC() tea.Cmd { - if strings.TrimSpace(m.textarea.Value()) != "" { - m.textarea.Reset() - m.resetHistoryBrowse() - m.paletteVisible = false - m.warning = "input cleared" - m.outcome = promptCancel - return nil - } - now := time.Now() - if !m.quitArmedUntil.IsZero() && now.Before(m.quitArmedUntil) { - m.outcome = promptExit - return tea.Quit - } - m.quitArmedUntil = now.Add(2 * time.Second) - m.warning = "press Ctrl+C again to quit" - return nil -} - -func (m *promptModel) submit() tea.Cmd { - text := strings.TrimSpace(m.textarea.Value()) - if text == "" { - m.warning = "" - return nil - } - if text == "/" && m.paletteVisible { - m.completeSlashCommand() - return nil - } - m.text = text - m.outcome = promptSubmit - return tea.Quit -} - -func (m *promptModel) View() string { - var parts []string - if m.activeSkill != "" { - parts = append(parts, helpStyle.Render("skill: "+m.activeSkill+" applies to the next message")) - } - if m.paletteVisible { - parts = append(parts, m.renderPalette()) - } - parts = append(parts, m.textarea.View()) - if m.warning != "" { - parts = append(parts, warnStyle.Render(m.warning)) - } - return strings.TrimRight(strings.Join(parts, "\n"), "\n") -} - -func (m *promptModel) canBrowseHistory() bool { - return !strings.Contains(m.textarea.Value(), "\n") && len(m.history) > 0 -} - -func (m *promptModel) browseHistory(delta int) { - if len(m.history) == 0 { - return - } - if m.historyCursor < 0 { - m.historyDraft = m.textarea.Value() - if delta < 0 { - m.historyCursor = len(m.history) - 1 - } else { - return - } - } else { - m.historyCursor += delta - } - if m.historyCursor < 0 { - m.historyCursor = 0 - } - if m.historyCursor >= len(m.history) { - m.historyCursor = -1 - m.setTextareaValueEnd(m.historyDraft) - m.updatePalette() - return - } - m.setTextareaValueEnd(m.history[m.historyCursor]) - m.updatePalette() - m.recomputeInputSize() -} - -func (m *promptModel) resetHistoryBrowse() { - m.historyCursor = -1 - m.historyDraft = "" -} - -func (m *promptModel) updatePalette() { - value := m.textarea.Value() - firstLine := value - if i := strings.IndexByte(firstLine, '\n'); i >= 0 { - firstLine = firstLine[:i] - } - trimmed := strings.TrimLeft(firstLine, " \t") - m.paletteVisible = strings.HasPrefix(trimmed, "/") - if !m.paletteVisible { - m.paletteCursor = 0 - return - } - if m.paletteCursor >= len(m.filteredCommands()) { - m.paletteCursor = 0 - } -} - -func (m *promptModel) filteredCommands() []slashCommand { - value := strings.TrimSpace(m.textarea.Value()) - if i := strings.IndexByte(value, '\n'); i >= 0 { - value = value[:i] - } - if value == "" || !strings.HasPrefix(value, "/") { - return nil - } - query := strings.Fields(value) - if len(query) > 0 { - value = query[0] - } - out := make([]slashCommand, 0, len(m.commands)) - for _, c := range m.commands { - if strings.HasPrefix(c.name, value) { - out = append(out, c) - } - } - if len(out) == 0 && value == "/" { - out = append(out, m.commands...) - } - return out -} - -func (m *promptModel) renderPalette() string { - rows := m.filteredCommands() - if len(rows) == 0 { - return helpStyle.Render(" no matching commands") - } - if len(rows) > 8 { - rows = rows[:8] - } - var b strings.Builder - for i, c := range rows { - marker := " " - name := commandNameStyle.Render(c.name) - if i == m.paletteCursor { - marker = promptArrowStyle.Render("› ") - name = paletteActiveStyle.Render(c.name) - } - fmt.Fprintf(&b, "%s%s %s\n", marker, name, helpStyle.Render(c.help)) - } - return strings.TrimRight(b.String(), "\n") -} - -func (m *promptModel) movePalette(delta int) { - rows := m.filteredCommands() - if len(rows) == 0 { - m.paletteCursor = 0 - return - } - m.paletteCursor += delta - if m.paletteCursor < 0 { - m.paletteCursor = len(rows) - 1 - } - if m.paletteCursor >= len(rows) { - m.paletteCursor = 0 - } -} - -func (m *promptModel) completeSlashCommand() { - rows := m.filteredCommands() - if len(rows) == 0 { - return - } - if m.paletteCursor >= len(rows) { - m.paletteCursor = 0 - } - m.setTextareaValueEnd(rows[m.paletteCursor].name + " ") - m.paletteVisible = false - m.warning = "" - m.recomputeInputSize() -} - -func (m *promptModel) setTextareaValueEnd(value string) { - m.textarea.SetValue(value) - for m.textarea.Line() < m.textarea.LineCount()-1 { - m.textarea.CursorDown() - } - m.textarea.CursorEnd() -} - -func (m *promptModel) recomputeInputSize() { - width := m.width - if width <= 0 { - width = 80 - } - if width < 24 { - width = 24 - } - m.textarea.SetWidth(width) - height := promptVisualHeight(m.textarea.Value(), width-2) - if height < 1 { - height = 1 - } - if height > 8 { - height = 8 - } - m.textarea.SetHeight(height) -} - -func promptVisualHeight(s string, width int) int { - if width < 8 { - width = 8 - } - if s == "" { - return 1 - } - total := 0 - for _, line := range strings.Split(s, "\n") { - cols := ansi.StringWidth(line) - rows := cols / width - if cols%width != 0 || rows == 0 { - rows++ - } - total += rows - } - return total -} diff --git a/internal/repl/prompt_test.go b/internal/repl/prompt_test.go deleted file mode 100644 index 9494239..0000000 --- a/internal/repl/prompt_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package repl - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestPromptCtrlCClearsDraftWithoutExiting(t *testing.T) { - m := newPromptModel(promptConfig{}) - m.textarea.SetValue("中文输入") - - model, cmd := m.Update(key("ctrl+c")) - if cmd != nil { - t.Fatalf("ctrl+c with draft should not quit") - } - pm := model.(*promptModel) - if pm.outcome != promptCancel { - t.Fatalf("outcome = %v, want promptCancel", pm.outcome) - } - if got := pm.textarea.Value(); got != "" { - t.Fatalf("draft = %q, want empty", got) - } -} - -func TestPromptCtrlCEmptyNeedsTwoPresses(t *testing.T) { - m := newPromptModel(promptConfig{}) - - model, cmd := m.Update(key("ctrl+c")) - if cmd != nil { - t.Fatalf("first ctrl+c should arm quit, not quit") - } - pm := model.(*promptModel) - if pm.outcome == promptExit { - t.Fatalf("first ctrl+c exited") - } - - model, cmd = pm.Update(key("ctrl+c")) - if cmd == nil { - t.Fatalf("second ctrl+c should return tea.Quit") - } - pm = model.(*promptModel) - if pm.outcome != promptExit { - t.Fatalf("outcome = %v, want promptExit", pm.outcome) - } -} - -func TestPromptHistoryRecall(t *testing.T) { - m := newPromptModel(promptConfig{History: []string{"first", "second"}}) - - model, _ := m.Update(key("up")) - pm := model.(*promptModel) - if got := pm.textarea.Value(); got != "second" { - t.Fatalf("up recalled %q, want second", got) - } - - model, _ = pm.Update(key("up")) - pm = model.(*promptModel) - if got := pm.textarea.Value(); got != "first" { - t.Fatalf("second up recalled %q, want first", got) - } - - model, _ = pm.Update(key("down")) - pm = model.(*promptModel) - if got := pm.textarea.Value(); got != "second" { - t.Fatalf("down recalled %q, want second", got) - } -} - -func TestPromptSlashPaletteCompletes(t *testing.T) { - m := newPromptModel(promptConfig{Commands: slashCommands}) - m.textarea.SetValue("/mo") - m.updatePalette() - - model, _ := m.Update(key("tab")) - pm := model.(*promptModel) - if got := pm.textarea.Value(); !strings.HasPrefix(got, "/model ") { - t.Fatalf("tab completed %q, want /model", got) - } -} - -func TestPromptCtrlJInsertsNewline(t *testing.T) { - m := newPromptModel(promptConfig{}) - m.textarea.SetValue("line one") - - model, _ := m.Update(key("ctrl+j")) - pm := model.(*promptModel) - if got := pm.textarea.Value(); got != "line one\n" { - t.Fatalf("value = %q, want newline inserted", got) - } -} - -func key(name string) tea.KeyMsg { - switch name { - case "ctrl+c": - return tea.KeyMsg{Type: tea.KeyCtrlC} - case "ctrl+j": - return tea.KeyMsg{Type: tea.KeyCtrlJ} - case "up": - return tea.KeyMsg{Type: tea.KeyUp} - case "down": - return tea.KeyMsg{Type: tea.KeyDown} - case "tab": - return tea.KeyMsg{Type: tea.KeyTab} - default: - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(name)} - } -} diff --git a/internal/repl/render.go b/internal/repl/render.go deleted file mode 100644 index 1ba3e78..0000000 --- a/internal/repl/render.go +++ /dev/null @@ -1,537 +0,0 @@ -package repl - -// render.go — terminal-friendly Tracer that writes to stdout. -// -// Layout per turn: -// -// [user types] -// -// ● tool_name compact summary -// └ compact result -// -// ● another_tool compact summary -// └ compact result - -import ( - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/eight-acres-lab/openmelon/internal/llm" -) - -var ( - replToolDotStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) - replToolStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Bold(true) - replToolSummaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - replResultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) - replErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) -) - -// terminalTracer renders runtime events to a terminal stream. -type terminalTracer struct { - w io.Writer - textInProgress bool // true if we're buffering an assistant markdown reply - markdown strings.Builder -} - -func newTerminalTracer(w io.Writer) *terminalTracer { - return &terminalTracer{w: w} -} - -func renderHistory(w io.Writer, messages []llm.Message) { - if len(messages) == 0 { - return - } - fmt.Fprintln(w) - fmt.Fprintf(w, "%s\n", renderHistoryRule(w, fmt.Sprintf("prior conversation (%d messages)", len(messages)))) - toolNames := make(map[string]string) - for i, msg := range messages { - before := i > 0 - renderHistoricMessage(w, msg, before, toolNames) - } - fmt.Fprintf(w, "%s\n", renderHistoryRule(w, "continue below")) -} - -func renderHistoricMessage(w io.Writer, msg llm.Message, spacer bool, toolNames map[string]string) { - switch msg.Role { - case llm.RoleSystem: - return - case llm.RoleUser: - if spacer { - fmt.Fprintln(w) - } - renderUserMessage(w, msg.Content) - case llm.RoleAssistant: - if strings.TrimSpace(msg.Content) != "" { - if spacer { - fmt.Fprintln(w) - } - renderMarkdownBlock(w, strings.TrimRight(msg.Content, "\n"), " ") - } - for _, call := range msg.ToolCalls { - if call.ID != "" { - toolNames[call.ID] = call.Name - } - if call.Name == "finish" { - continue - } - renderToolCallBlock(w, call) - } - case llm.RoleTool: - toolName := toolNames[msg.ToolCallID] - if toolName == "finish" { - renderFinishResult(w, msg.Content, nil) - fmt.Fprintln(w) - return - } - if errMsg := toolErrorMessage(msg.Content); errMsg != "" { - renderWrappedText(w, replErrorStyle.Render("└ error: "+errMsg), " ") - } else { - renderToolResultBlock(w, toolName, msg.Content, nil) - } - fmt.Fprintln(w) - } -} - -func renderUserMessage(w io.Writer, content string) { - lines := strings.Split(content, "\n") - if len(lines) == 0 { - fmt.Fprintln(w, ">") - return - } - renderWrappedText(w, "> "+lines[0], "") - for _, line := range lines[1:] { - renderWrappedText(w, line, " ") - } -} - -func (t *terminalTracer) OnTurnStart(int) { /* nothing — we let the prompt arrow do it */ } - -func (t *terminalTracer) OnText(delta string) { - t.textInProgress = true - t.markdown.WriteString(delta) - t.flushMarkdown(false) - if f, ok := t.w.(*os.File); ok { - // Best-effort flush so users see incremental output even when - // stdout is line-buffered. - _ = f.Sync() - } -} - -func (t *terminalTracer) OnToolCall(call llm.ToolCall) { - if t.textInProgress { - t.flushMarkdown(true) - t.textInProgress = false - } - if call.Name == "finish" { - return - } - renderToolCallBlock(t.w, call) -} - -func (t *terminalTracer) OnToolResult(call llm.ToolCall, content string, err error) { - if call.Name == "finish" { - renderFinishResult(t.w, content, err) - return - } - renderToolResultBlock(t.w, call.Name, content, err) - fmt.Fprintln(t.w) -} - -func (t *terminalTracer) OnTurnEnd(_ int, _ llm.FinishReason, _ llm.Usage) { - if t.textInProgress { - t.flushMarkdown(true) - t.textInProgress = false - } -} - -// prettyArgs collapses the JSON args to a single line for display. -// If parsing fails, falls back to the raw string truncated. -func prettyArgs(raw json.RawMessage) string { - if len(raw) == 0 { - return "" - } - var v any - if err := json.Unmarshal(raw, &v); err != nil { - return truncateOneLine(string(raw), 80) - } - b, err := json.Marshal(v) - if err != nil { - return truncateOneLine(string(raw), 80) - } - return truncateOneLine(string(b), 120) -} - -func truncateOneLine(s string, n int) string { - s = strings.ReplaceAll(s, "\n", " ") - s = strings.Join(strings.Fields(s), " ") - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -func toolErrorMessage(content string) string { - var obj map[string]any - if err := json.Unmarshal([]byte(content), &obj); err != nil { - return "" - } - v, ok := obj["error"] - if !ok { - return "" - } - switch msg := v.(type) { - case string: - return strings.TrimSpace(msg) - default: - return strings.TrimSpace(fmt.Sprint(msg)) - } -} - -func renderToolCallBlock(w io.Writer, call llm.ToolCall) { - fmt.Fprintln(w) - renderWrappedText(w, formatToolCallLine(call), "") -} - -func renderToolResultBlock(w io.Writer, toolName, content string, err error) { - if err != nil { - renderWrappedText(w, replErrorStyle.Render("└ error: "+err.Error()), " ") - return - } - if msg := toolErrorMessage(content); msg != "" { - renderWrappedText(w, replErrorStyle.Render("└ error: "+msg), " ") - return - } - summary := toolResultSummary(toolName, content) - if summary == "" { - summary = "(done)" - } - renderWrappedText(w, replResultStyle.Render("└ "+summary), " ") -} - -func renderFinishResult(w io.Writer, content string, err error) { - if err != nil { - renderWrappedText(w, replErrorStyle.Render("error: "+err.Error()), " ") - fmt.Fprintln(w) - return - } - if msg := toolErrorMessage(content); msg != "" { - renderWrappedText(w, replErrorStyle.Render("error: "+msg), " ") - fmt.Fprintln(w) - return - } - obj := jsonObjectBytes([]byte(content)) - if len(obj) == 0 { - renderMarkdownBlock(w, content, " ") - fmt.Fprintln(w) - return - } - summary := stringField(obj, "summary") - if summary != "" { - renderMarkdownBlock(w, summary, " ") - } - artifacts := artifactStrings(obj) - if len(artifacts) > 0 { - if summary != "" { - fmt.Fprintln(w) - } - for _, path := range artifacts { - renderWrappedText(w, replResultStyle.Render("artifact: "+path), " ") - } - } - if summary != "" || len(artifacts) > 0 { - fmt.Fprintln(w) - } -} - -func formatToolCallLine(call llm.ToolCall) string { - name := replToolStyle.Render(call.Name) - summary := toolCallSummary(call) - if summary == "" { - return fmt.Sprintf("%s %s", replToolDotStyle.Render("●"), name) - } - return fmt.Sprintf("%s %s %s", replToolDotStyle.Render("●"), name, replToolSummaryStyle.Render(summary)) -} - -func toolCallSummary(call llm.ToolCall) string { - obj := jsonObject(call.Arguments) - if len(obj) == 0 { - return prettyArgs(call.Arguments) - } - switch call.Name { - case "generate_image": - return joinSummaryParts( - stringField(obj, "label"), - stringField(obj, "size"), - shortField(obj, "prompt", 110), - countField(obj, "reference_images", "refs"), - ) - case "save_artifact": - return joinSummaryParts( - stringField(obj, "slug"), - shortPath(stringField(obj, "image_path")), - ) - case "register_asset": - return joinSummaryParts( - stringField(obj, "space_id"), - firstNonEmpty(stringField(obj, "id"), stringField(obj, "kind")), - shortField(obj, "description", 90), - ) - case "finish": - return shortField(obj, "summary", 110) - default: - return fallbackArgSummary(obj, call.Arguments) - } -} - -func toolResultSummary(toolName, content string) string { - if strings.TrimSpace(content) == "" { - return "(no output)" - } - if toolName == "finish" { - if obj := jsonObjectBytes([]byte(content)); len(obj) > 0 { - return joinSummaryParts(shortField(obj, "summary", 120), artifactsCount(obj)) - } - } - if obj := jsonObjectBytes([]byte(content)); len(obj) > 0 { - if path := stringField(obj, "path"); path != "" { - switch toolName { - case "generate_image": - return "saved " + shortPath(path) - case "save_artifact": - return "artifact " + shortPath(path) - default: - return shortPath(path) - } - } - if id := stringField(obj, "id"); id != "" { - return "ok " + id - } - if summary := stringField(obj, "summary"); summary != "" { - return truncateOneLine(summary, 140) - } - if ok, exists := obj["ok"]; exists { - return "ok " + fmt.Sprint(ok) - } - } - if arr := jsonArrayObjects([]byte(content)); len(arr) > 0 { - first := arr[0] - if path := stringField(first, "path"); path != "" { - if len(arr) == 1 { - return "saved " + shortPath(path) - } - return fmt.Sprintf("saved %d files, first %s", len(arr), shortPath(path)) - } - if id := stringField(first, "id"); id != "" { - if len(arr) == 1 { - return "ok " + id - } - return fmt.Sprintf("ok %d items, first %s", len(arr), id) - } - } - return truncateOneLine(content, 180) -} - -func jsonObject(raw json.RawMessage) map[string]any { - return jsonObjectBytes(raw) -} - -func jsonObjectBytes(raw []byte) map[string]any { - var obj map[string]any - if len(raw) == 0 || json.Unmarshal(raw, &obj) != nil { - return nil - } - return obj -} - -func jsonArrayObjects(raw []byte) []map[string]any { - var arr []map[string]any - if len(raw) == 0 || json.Unmarshal(raw, &arr) != nil { - return nil - } - return arr -} - -func stringField(obj map[string]any, key string) string { - v, ok := obj[key] - if !ok || v == nil { - return "" - } - switch typed := v.(type) { - case string: - return strings.TrimSpace(typed) - case fmt.Stringer: - return strings.TrimSpace(typed.String()) - default: - return strings.TrimSpace(fmt.Sprint(typed)) - } -} - -func shortField(obj map[string]any, key string, limit int) string { - return truncateOneLine(stringField(obj, key), limit) -} - -func countField(obj map[string]any, key, label string) string { - v, ok := obj[key] - if !ok || v == nil { - return "" - } - switch typed := v.(type) { - case []any: - if len(typed) == 0 { - return "" - } - return fmt.Sprintf("%d %s", len(typed), label) - case []string: - if len(typed) == 0 { - return "" - } - return fmt.Sprintf("%d %s", len(typed), label) - default: - return "" - } -} - -func artifactsCount(obj map[string]any) string { - if artifacts := artifactStrings(obj); len(artifacts) > 0 { - return fmt.Sprintf("%d artifact(s)", len(artifacts)) - } - return "" -} - -func artifactStrings(obj map[string]any) []string { - v, ok := obj["artifacts"] - if !ok || v == nil { - return nil - } - switch typed := v.(type) { - case []any: - out := make([]string, 0, len(typed)) - for _, item := range typed { - if s := strings.TrimSpace(fmt.Sprint(item)); s != "" { - out = append(out, s) - } - } - return out - case []string: - out := make([]string, 0, len(typed)) - for _, item := range typed { - if s := strings.TrimSpace(item); s != "" { - out = append(out, s) - } - } - return out - default: - return nil - } -} - -func fallbackArgSummary(obj map[string]any, raw json.RawMessage) string { - for _, key := range []string{"name", "id", "title", "space_id", "query", "path", "command", "summary", "description"} { - if v := shortField(obj, key, 100); v != "" { - return v - } - } - return prettyArgs(raw) -} - -func joinSummaryParts(parts ...string) string { - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - out = append(out, part) - } - } - return strings.Join(out, " · ") -} - -func shortPath(path string) string { - path = strings.TrimSpace(path) - if path == "" { - return "" - } - dir := filepath.Base(filepath.Dir(path)) - base := filepath.Base(path) - if dir == "." || dir == string(filepath.Separator) || dir == "" { - return base - } - return filepath.Join(dir, base) -} - -func renderHistoryRule(w io.Writer, label string) string { - width := terminalWidth(w) - rightWrapBuffer - if width < 28 { - width = 28 - } - text := " " + label + " " - remaining := width - ansi.StringWidth(text) - if remaining < 4 { - return dividerStyle.Render("── " + label) - } - left := remaining / 2 - right := remaining - left - return dividerStyle.Render(strings.Repeat("─", left) + text + strings.Repeat("─", right)) -} - -func (t *terminalTracer) flushMarkdown(force bool) { - raw := t.markdown.String() - if raw == "" { - return - } - if !force && !hasStableMarkdownBoundary(raw) { - return - } - renderMarkdownBlock(t.w, raw, " ") - fmt.Fprintln(t.w) - t.markdown.Reset() -} - -func hasStableMarkdownBoundary(raw string) bool { - trimmed := strings.TrimRight(raw, " \t") - return strings.HasSuffix(trimmed, "\n\n") -} - -func renderMarkdownBlock(w io.Writer, markdown, prefix string) { - markdown = strings.TrimRight(markdown, "\n") - if strings.TrimSpace(markdown) == "" { - return - } - rendered := renderMarkdownWithWidth(markdown, wrappedTextWidth(w, prefix)) - renderWrappedText(w, rendered, prefix) -} - -func renderWrappedText(w io.Writer, text, prefix string) { - if isTerminalWriter(w) { - for _, line := range strings.Split(text, "\n") { - fmt.Fprintln(w, prefix+line) - } - return - } - width := wrappedTextWidth(w, prefix) - for _, line := range strings.Split(text, "\n") { - for _, wrapped := range wrapDisplayLine(line, width) { - fmt.Fprintln(w, prefix+wrapped) - } - } -} - -// --- jsonl helper used by /save --- - -type jsonlEncoder struct{ w io.Writer } - -func newJSONLEncoder(w io.Writer) *jsonlEncoder { return &jsonlEncoder{w: w} } -func (e *jsonlEncoder) encode(v any) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - _, err = e.w.Write(append(b, '\n')) - return err -} diff --git a/internal/repl/repl.go b/internal/repl/repl.go deleted file mode 100644 index f534f41..0000000 --- a/internal/repl/repl.go +++ /dev/null @@ -1,849 +0,0 @@ -// Package repl is openmelon's terminal interaction loop. -// -// The default UI deliberately keeps transcript output in the normal -// terminal scrollback while using a small prompt-only Bubble Tea editor -// for the current input. That gives us proper Unicode editing, history, -// multiline input, slash completion, and Ctrl-C handling without owning -// the entire screen. -package repl - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "os" - "os/signal" - "strings" - "syscall" - - osc52 "github.com/aymanbagabas/go-osc52/v2" - "github.com/eight-acres-lab/openmelon/internal/continuity" - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/onboard" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/session" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/tools" - "golang.org/x/term" -) - -// Options configures a REPL. -type Options struct { - // Workdir is the project root. - Workdir string - - // Project is the loaded project config (used in the system prompt). - Project *projectx.Project - - // Runtime carries the LLM. The REPL sets Tracer + (optionally) - // rebuilds Registry via WireSession after creating the session. - Runtime *runtime.Runtime - - // WireSession is called once after the REPL creates a session, with - // the session directory. Implementations should rebuild any tools - // that need to write into the session (most notably generate_image, - // which writes images into /) and assign the new registry - // onto Runtime.Registry. Optional. - WireSession func(sessionDir string) - - // SystemPrompt is sent on the first turn. - SystemPrompt string - - // SessionIntent is recorded into the session's meta.json. - SessionIntent string - - // ResumedFrom, when non-empty, records the prior session id in the - // new session metadata. - ResumedFrom string - - // InitialHistory seeds a resumed conversation. - InitialHistory []llm.Message - - // Provider / Model are recorded in the session metadata and shown - // in the startup banner. - Provider string - Model string - ModelTag string - - // Image provider/model are shown in status and used by /model-image. - ImageProvider string - ImageModel string - ImageTag string - - // Hot-swap callbacks for slash commands. They rebuild the actual - // clients in cmd/openmelon and persist the project defaults. - RebuildLLM func(model string) (string, error) - RebuildImageModel func(provider, model string) (string, error) - - BashMode projectx.BashPermissionMode - ReasoningEffort string - SaveSettings func(projectx.Settings) error - - // InstallApprove wires a terminal approval prompt into tools.Env. - InstallApprove func(approve func(req tools.ApprovalRequest) tools.ApprovalDecision) - - // In / Out / Err default to os.Stdin / os.Stdout / os.Stderr. - In io.Reader - Out io.Writer - Err io.Writer -} - -// slashCommand is one row in the prompt slash palette. -type slashCommand struct { - name string - help string -} - -var slashCommands = []slashCommand{ - {"/help", "show commands"}, - {"/skill", "apply a skillplus package to the next message"}, - {"/model", "switch the LLM model"}, - {"/model-image", "switch or disable image generation"}, - {"/settings", "show or change project settings"}, - {"/copy", "copy the conversation transcript via OSC52"}, - {"/clear", "clear screen and forget conversation history"}, - {"/history", "print the message log so far"}, - {"/save", "write conversation to a jsonl file"}, - {"/session", "show the session directory"}, - {"/events", "show recent session lifecycle events"}, - {"/space", "show a creative space summary"}, - {"/compact", "print a compaction draft"}, - {"/exit", "exit"}, -} - -type app struct { - opts Options - sess *session.Session - - interactive bool - scanner *bufio.Scanner - sigCh chan os.Signal - - history []llm.Message - persistedUpTo int - inputHistory []string - - llmTag string - imageTag string - provider string - llmModel string - imageProvider string - imageModel string - bashMode projectx.BashPermissionMode - reasoningEffort string - activeSkill string -} - -// Run enters the REPL. Returns when the user exits with /exit, EOF -// (Ctrl-D), or SIGTERM. -func Run(ctx context.Context, opts Options) error { - if opts.In == nil { - opts.In = os.Stdin - } - if opts.Out == nil { - opts.Out = os.Stdout - } - if opts.Err == nil { - opts.Err = os.Stderr - } - if opts.Runtime == nil { - return errors.New("repl: Runtime is required") - } - if opts.Project == nil { - return errors.New("repl: Project is required") - } - - sess, err := session.NewResume(opts.Workdir, opts.Project.ID, opts.SessionIntent, opts.ResumedFrom) - if err != nil { - return fmt.Errorf("repl: session: %w", err) - } - defer sess.Close() - _ = sess.SetRuntimeInfo(opts.Provider, opts.Model) - opts.Runtime.Hooks = hooks.ChainManagers(opts.Runtime.Hooks, sess.HookRecorder()) - - if opts.WireSession != nil { - opts.WireSession(sess.Dir) - } - tr := newTerminalTracer(opts.Out) - opts.Runtime.Tracer = tr - - a := &app{ - opts: opts, - sess: sess, - interactive: isInteractive(opts.In, opts.Out), - history: append([]llm.Message(nil), opts.InitialHistory...), - persistedUpTo: len(opts.InitialHistory), - llmTag: firstNonEmpty(opts.ModelTag, composeModelTag(opts.Provider, opts.Model)), - imageTag: firstNonEmpty(opts.ImageTag, composeModelTag(opts.ImageProvider, opts.ImageModel)), - provider: opts.Provider, - llmModel: opts.Model, - imageProvider: opts.ImageProvider, - imageModel: opts.ImageModel, - bashMode: opts.BashMode, - reasoningEffort: opts.ReasoningEffort, - sigCh: make(chan os.Signal, 4), - } - if !a.interactive { - a.scanner = bufio.NewScanner(opts.In) - a.scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - } - if opts.InstallApprove != nil { - if a.interactive { - opts.InstallApprove(newApprovalPromptReader(opts.In, opts.Out)) - } else { - opts.InstallApprove(newApprovalPrompt(a.scanner, opts.Out)) - } - } - signal.Notify(a.sigCh, os.Interrupt, syscall.SIGTERM) - defer signal.Stop(a.sigCh) - - printBanner(opts.Out, a.bannerInfo()) - if len(a.history) > 0 { - fmt.Fprintf(opts.Out, "%s\n", helpStyle.Render(fmt.Sprintf("loaded %d prior messages", len(a.history)))) - renderHistory(opts.Out, a.history) - } - - for { - line, ok, err := a.readLine(ctx) - if err != nil { - return err - } - if !ok { - fmt.Fprintln(opts.Out) - return nil - } - line = strings.TrimSpace(line) - if line == "" { - continue - } - a.recordInput(line) - if strings.HasPrefix(line, "/") { - done, err := a.handleSlash(ctx, line) - if err != nil { - a.printError("openmelon", err.Error()) - } - if done { - return nil - } - continue - } - done, err := a.runTurn(ctx, line) - if err != nil { - return err - } - if done { - return nil - } - } -} - -func (a *app) readLine(ctx context.Context) (string, bool, error) { - if !a.interactive { - fmt.Fprint(a.opts.Out, "\n> ") - if !a.scanner.Scan() { - if err := a.scanner.Err(); err != nil { - return "", false, fmt.Errorf("repl: read input: %w", err) - } - return "", false, nil - } - return a.scanner.Text(), true, nil - } - res, err := readInteractivePrompt(ctx, promptConfig{ - In: a.opts.In, - Out: a.opts.Out, - History: a.inputHistory, - ActiveSkill: a.activeSkill, - Commands: slashCommands, - }) - if err != nil { - return "", false, fmt.Errorf("repl: prompt: %w", err) - } - fmt.Fprintln(a.opts.Out) - switch res.outcome { - case promptSubmit: - return res.text, true, nil - case promptExit: - return "", false, nil - case promptCancel: - return "", true, nil - default: - return "", true, nil - } -} - -func (a *app) runTurn(ctx context.Context, line string) (bool, error) { - _ = a.sess.AppendPrompt("user", line) - userInput := a.applyActiveSkill(line) - in := runtime.RunInput{UserInput: userInput} - if len(a.history) == 0 { - in.SystemPrompt = a.opts.SystemPrompt - } else { - in.History = a.history - } - - fmt.Fprintf(a.opts.Out, "%s\n", helpStyle.Render("["+composePromptStatusLine(a.bannerInfo())+"]")) - turnCtx, cancelTurn := context.WithCancel(ctx) - defer cancelTurn() - done := make(chan struct{}) - forceExit := make(chan struct{}) - go func() { - interrupts := 0 - for { - select { - case <-done: - return - case <-ctx.Done(): - cancelTurn() - return - case sig := <-a.sigCh: - if sig == syscall.SIGTERM { - cancelTurn() - safeClose(forceExit) - return - } - interrupts++ - cancelTurn() - if interrupts == 1 { - fmt.Fprintln(a.opts.Err) - fmt.Fprintln(a.opts.Err, warnStyle.Render("[interrupting; press Ctrl+C again to quit]")) - continue - } - safeClose(forceExit) - return - } - } - }() - - res, runErr := a.opts.Runtime.Run(turnCtx, in) - close(done) - cancelTurn() - - if res != nil { - a.history = res.Messages - if a.persistedUpTo < len(a.history) { - _ = a.sess.AppendMessages(a.history[a.persistedUpTo:]) - a.persistedUpTo = len(a.history) - } - } - if runErr != nil { - if errors.Is(runErr, context.Canceled) { - fmt.Fprintln(a.opts.Err, warnStyle.Render("[interrupted]")) - } else { - a.printError("openmelon", runErr.Error()) - } - } else if res != nil && res.Finished { - fmt.Fprintln(a.opts.Out, helpStyle.Render("[turn complete]")) - } - select { - case <-forceExit: - return true, nil - default: - return false, nil - } -} - -func (a *app) applyActiveSkill(text string) string { - if a.activeSkill == "" { - return text - } - skill := a.activeSkill - a.activeSkill = "" - return fmt.Sprintf( - "Apply the skill %q to this request: first call compile_skill with skill=%q (BARE slug, no 'skillplus:' prefix) to fetch the package's prompt + output schema, then proceed.\n\n%s", - skill, skill, text, - ) -} - -func (a *app) recordInput(text string) { - text = strings.TrimSpace(text) - if text == "" { - return - } - if n := len(a.inputHistory); n > 0 && a.inputHistory[n-1] == text { - return - } - a.inputHistory = append(a.inputHistory, text) - if len(a.inputHistory) > 200 { - a.inputHistory = a.inputHistory[len(a.inputHistory)-200:] - } -} - -func (a *app) handleSlash(ctx context.Context, line string) (bool, error) { - parts := strings.Fields(line) - if len(parts) == 0 { - return false, nil - } - cmd := parts[0] - switch cmd { - case "/exit", "/quit", "/q": - fmt.Fprintln(a.opts.Out, "bye.") - return true, nil - case "/help", "/?": - a.printHelp() - case "/clear": - a.history = nil - a.persistedUpTo = 0 - if a.interactive { - clearScreen(a.opts.Out) - printBanner(a.opts.Out, a.bannerInfo()) - } - fmt.Fprintln(a.opts.Out, helpStyle.Render("(history cleared)")) - case "/history": - if len(a.history) == 0 { - fmt.Fprintln(a.opts.Out, helpStyle.Render("(no conversation history)")) - break - } - renderHistory(a.opts.Out, a.history) - case "/save": - return false, a.saveHistory(parts) - case "/session": - fmt.Fprintln(a.opts.Out, a.sess.Dir) - case "/events": - return false, a.printEvents() - case "/space": - return false, a.printSpace(parts) - case "/compact": - return false, a.printCompact(parts) - case "/copy": - return false, a.copyTranscript() - case "/model": - return false, a.switchModel(parts) - case "/model-image": - return false, a.switchImageModel(parts) - case "/settings", "/config": - return false, a.handleSettings(parts) - case "/skill": - return false, a.handleSkill(ctx, parts) - default: - return false, fmt.Errorf("unknown command: %s (try /help)", cmd) - } - return false, nil -} - -func (a *app) printHelp() { - for _, c := range slashCommands { - fmt.Fprintf(a.opts.Out, " %-13s %s\n", c.name, helpStyle.Render(c.help)) - } - fmt.Fprintln(a.opts.Out, helpStyle.Render("Input: Enter submit · Ctrl+J newline · ↑/↓ history · Tab completes slash commands · Ctrl+C twice quits.")) -} - -func (a *app) saveHistory(parts []string) error { - if len(parts) < 2 { - return errors.New("/save: usage: /save ") - } - f, err := os.Create(parts[1]) - if err != nil { - return fmt.Errorf("/save: %w", err) - } - defer f.Close() - enc := newJSONLEncoder(f) - for _, m := range a.history { - if err := enc.encode(m); err != nil { - return err - } - } - fmt.Fprintf(a.opts.Out, "saved %d messages -> %s\n", len(a.history), parts[1]) - return nil -} - -func (a *app) printEvents() error { - events, err := session.LoadEvents(a.opts.Workdir, a.sess.ID, 20) - if err != nil { - return fmt.Errorf("/events: %w", err) - } - if len(events) == 0 { - fmt.Fprintln(a.opts.Out, helpStyle.Render("(no events recorded yet)")) - return nil - } - for _, e := range events { - a.printWrapped(fmt.Sprintf("%s step=%d tool=%s space=%s status=%s", e.Type, e.Step, e.Tool, e.SpaceID, e.Status), " ") - } - return nil -} - -func (a *app) printSpace(parts []string) error { - if len(parts) != 2 { - return errors.New("/space: usage: /space ") - } - p, err := continuity.BuildContextPacket(a.opts.Workdir, a.opts.Project.ID, parts[1]) - if err != nil { - return fmt.Errorf("/space: %w", err) - } - a.printWrapped(fmt.Sprintf("%s (%s): %s", p.Space.ID, p.Space.Status, p.Space.Name), "") - a.printWrapped(fmt.Sprintf("%d decisions · %d feedback · %d episodes · %d assets", len(p.RecentDecisions), len(p.RecentFeedback), len(p.RecentEpisodes), len(p.Assets)), " ") - return nil -} - -func (a *app) printCompact(parts []string) error { - if len(parts) != 2 { - return errors.New("/compact: usage: /compact ") - } - body, err := continuity.BuildCompactionDraft(a.opts.Workdir, a.opts.Project.ID, parts[1]) - if err != nil { - return fmt.Errorf("/compact: %w", err) - } - a.printWrapped(body, "") - return nil -} - -func (a *app) copyTranscript() error { - text := plainTranscript(a.history) - if strings.TrimSpace(text) == "" { - return errors.New("/copy: nothing to copy") - } - seq := osc52.New(text) - if os.Getenv("TMUX") != "" { - seq = seq.Tmux() - } - if _, err := seq.WriteTo(a.opts.Err); err != nil { - return fmt.Errorf("/copy: %w", err) - } - fmt.Fprintf(a.opts.Out, "%s\n", helpStyle.Render(fmt.Sprintf("copied transcript (%d chars)", len([]rune(text))))) - return nil -} - -func (a *app) switchModel(parts []string) error { - if a.opts.RebuildLLM == nil { - return errors.New("/model: model switching is not wired") - } - if len(parts) < 2 { - a.printModelPresets(false) - fmt.Fprintln(a.opts.Out, helpStyle.Render("usage: /model ")) - return nil - } - model := strings.TrimSpace(parts[1]) - tag, err := a.opts.RebuildLLM(model) - if err != nil { - return fmt.Errorf("/model: %w", err) - } - a.llmModel = model - a.llmTag = tag - if tag == "" { - a.llmTag = composeModelTag(a.provider, model) - } - fmt.Fprintln(a.opts.Out, helpStyle.Render("(LLM: "+a.llmTag+")")) - return nil -} - -func (a *app) switchImageModel(parts []string) error { - if a.opts.RebuildImageModel == nil { - return errors.New("/model-image: image model switching is not wired") - } - if len(parts) < 2 { - a.printModelPresets(true) - fmt.Fprintln(a.opts.Out, helpStyle.Render("usage: /model-image | /model-image | /model-image off")) - return nil - } - if parts[1] == "off" || parts[1] == "disable" || parts[1] == "none" { - tag, err := a.opts.RebuildImageModel("", "") - if err != nil { - return fmt.Errorf("/model-image: %w", err) - } - a.imageProvider, a.imageModel, a.imageTag = "", "", tag - fmt.Fprintln(a.opts.Out, helpStyle.Render("(image generation disabled)")) - return nil - } - provider := a.imageProvider - model := parts[1] - if len(parts) >= 3 { - provider = parts[1] - model = parts[2] - } - if provider == "" { - provider = a.provider - } - tag, err := a.opts.RebuildImageModel(provider, model) - if err != nil { - return fmt.Errorf("/model-image: %w", err) - } - a.imageProvider, a.imageModel, a.imageTag = provider, model, tag - if a.imageTag == "" { - a.imageTag = composeModelTag(provider, model) - } - fmt.Fprintln(a.opts.Out, helpStyle.Render("(image model: "+a.imageTag+")")) - return nil -} - -func (a *app) printModelPresets(image bool) { - provider := a.provider - if image && a.imageProvider != "" { - provider = a.imageProvider - } - info, ok := onboard.ProviderBySlug(provider) - if !ok { - fmt.Fprintf(a.opts.Out, "%s\n", helpStyle.Render("no presets for provider "+provider)) - return - } - presets := info.LLMPresets - if image { - presets = info.ImagePresets - } - for _, p := range presets { - a.printWrapped(fmt.Sprintf("%s %s", p.ID, p.Subtitle), " ") - } -} - -func (a *app) handleSettings(parts []string) error { - if len(parts) == 1 { - fmt.Fprintf(a.opts.Out, "bash_permission_mode: %s\n", a.bashMode) - if a.reasoningEffort == "" { - fmt.Fprintln(a.opts.Out, "reasoning_effort: auto") - } else { - fmt.Fprintf(a.opts.Out, "reasoning_effort: %s\n", a.reasoningEffort) - } - fmt.Fprintln(a.opts.Out, helpStyle.Render("usage: /settings bash strict|auto|trusted")) - fmt.Fprintln(a.opts.Out, helpStyle.Render("usage: /settings reasoning auto|medium|high|xhigh")) - return nil - } - if a.opts.SaveSettings == nil { - return errors.New("/settings: saving settings is not wired") - } - next := projectx.Settings{BashPermissionMode: a.bashMode, ReasoningEffort: a.reasoningEffort} - switch parts[1] { - case "bash": - if len(parts) < 3 { - return errors.New("/settings bash: expected strict|auto|trusted") - } - switch projectx.BashPermissionMode(parts[2]) { - case projectx.BashModeStrict, projectx.BashModeAuto, projectx.BashModeTrusted: - next.BashPermissionMode = projectx.BashPermissionMode(parts[2]) - default: - return errors.New("/settings bash: expected strict|auto|trusted") - } - case "reasoning": - if len(parts) < 3 { - return errors.New("/settings reasoning: expected auto|medium|high|xhigh") - } - if parts[2] == "auto" { - next.ReasoningEffort = "" - } else { - switch parts[2] { - case "medium", "high", "xhigh": - next.ReasoningEffort = parts[2] - default: - return errors.New("/settings reasoning: expected auto|medium|high|xhigh") - } - } - default: - return errors.New("/settings: expected bash or reasoning") - } - if err := a.opts.SaveSettings(next); err != nil { - return fmt.Errorf("/settings: %w", err) - } - a.bashMode = next.EffectiveBashMode() - a.reasoningEffort = next.EffectiveReasoningEffort() - fmt.Fprintf(a.opts.Out, "%s\n", helpStyle.Render(fmt.Sprintf("(settings: bash=%s reasoning=%s)", a.bashMode, emptyAsAuto(a.reasoningEffort)))) - return nil -} - -func (a *app) handleSkill(ctx context.Context, parts []string) error { - if len(parts) == 1 { - skills, err := skillplus.ListSkills(ctx) - if err != nil { - return fmt.Errorf("/skill: %w", err) - } - if len(skills) == 0 { - fmt.Fprintln(a.opts.Out, helpStyle.Render("(no skillplus packages found)")) - return nil - } - for _, s := range skills { - a.printWrapped(fmt.Sprintf("%s %s", s.ID, s.Description), " ") - } - fmt.Fprintln(a.opts.Out, helpStyle.Render("usage: /skill or /skill clear")) - return nil - } - arg := parts[1] - if arg == "clear" || arg == "off" || arg == "none" { - if a.activeSkill == "" { - fmt.Fprintln(a.opts.Out, helpStyle.Render("(no active skill)")) - } else { - fmt.Fprintln(a.opts.Out, helpStyle.Render("(skill cleared: "+a.activeSkill+")")) - a.activeSkill = "" - } - return nil - } - a.activeSkill = arg - fmt.Fprintln(a.opts.Out, helpStyle.Render("(skill: "+arg+") applies to your next message")) - return nil -} - -func (a *app) bannerInfo() bannerInfo { - return bannerInfo{ - Workdir: a.opts.Workdir, - Project: a.opts.Project, - Session: a.sess, - ResumedFrom: a.opts.ResumedFrom, - LLMTag: a.llmTag, - ImageTag: a.imageTag, - BashMode: a.bashMode, - ReasoningEffort: a.reasoningEffort, - } -} - -func (a *app) printWrapped(text, prefix string) { - if isTerminalWriter(a.opts.Out) { - for _, line := range strings.Split(text, "\n") { - fmt.Fprintln(a.opts.Out, prefix+line) - } - return - } - width := terminalWidth(a.opts.Out) - len(prefix) - for _, line := range strings.Split(text, "\n") { - for _, wrapped := range wrapDisplayLine(line, width) { - fmt.Fprintln(a.opts.Out, prefix+wrapped) - } - } -} - -func (a *app) printError(label, text string) { - width := terminalWidth(a.opts.Err) - len(label) - 2 - first := true - for _, line := range strings.Split(text, "\n") { - for _, wrapped := range wrapDisplayLine(line, width) { - if first { - fmt.Fprintf(a.opts.Err, "%s: %s\n", errorStyle.Render(label), errorStyle.Render(wrapped)) - first = false - } else { - fmt.Fprintf(a.opts.Err, "%s %s\n", strings.Repeat(" ", len(label)), errorStyle.Render(wrapped)) - } - } - } -} - -func isInteractive(in io.Reader, out io.Writer) bool { - inFile, ok := in.(*os.File) - if !ok { - return false - } - outFile, ok := out.(*os.File) - if !ok { - return false - } - return term.IsTerminal(int(inFile.Fd())) && term.IsTerminal(int(outFile.Fd())) -} - -func composeModelTag(provider, model string) string { - switch { - case provider != "" && model != "": - return provider + ":" + model - case model != "": - return model - case provider != "": - return provider - default: - return "" - } -} - -func emptyAsAuto(v string) string { - if v == "" { - return "auto" - } - return v -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - return "" -} - -func plainTranscript(history []llm.Message) string { - var b strings.Builder - for _, m := range history { - switch m.Role { - case llm.RoleSystem: - continue - case llm.RoleUser: - fmt.Fprintf(&b, "> %s\n\n", m.Content) - case llm.RoleAssistant: - if strings.TrimSpace(m.Content) != "" { - fmt.Fprintf(&b, "%s\n\n", strings.TrimSpace(m.Content)) - } - for _, tc := range m.ToolCalls { - fmt.Fprintf(&b, "tool %s(%s)\n", tc.Name, prettyArgs(tc.Arguments)) - } - case llm.RoleTool: - fmt.Fprintf(&b, "result %s\n\n", m.Content) - } - } - return strings.TrimSpace(b.String()) -} - -func newApprovalPrompt(scanner *bufio.Scanner, out io.Writer) func(tools.ApprovalRequest) tools.ApprovalDecision { - return func(req tools.ApprovalRequest) tools.ApprovalDecision { - renderApprovalRequest(out, req) - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - fmt.Fprintf(out, "approval failed: %v\n", err) - } - return tools.ApprovalDecision{} - } - return parseApprovalAnswer(scanner.Text()) - } -} - -func newApprovalPromptReader(in io.Reader, out io.Writer) func(tools.ApprovalRequest) tools.ApprovalDecision { - reader := bufio.NewReader(in) - return func(req tools.ApprovalRequest) tools.ApprovalDecision { - renderApprovalRequest(out, req) - line, err := reader.ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - fmt.Fprintf(out, "approval failed: %v\n", err) - return tools.ApprovalDecision{} - } - return parseApprovalAnswer(line) - } -} - -func renderApprovalRequest(out io.Writer, req tools.ApprovalRequest) { - fmt.Fprintln(out) - fmt.Fprintln(out, warnStyle.Render("Do you want to proceed?")) - if req.Description != "" { - for _, line := range wrapDisplayLine(req.Description, terminalWidth(out)-10) { - fmt.Fprintf(out, " Reason: %s\n", line) - } - } - if req.Command != "" { - first := true - for _, line := range wrapDisplayLine(req.Command, terminalWidth(out)-11) { - if first { - fmt.Fprintf(out, " Command: %s\n", line) - first = false - } else { - fmt.Fprintf(out, " %s\n", line) - } - } - } - if req.Binary != "" { - fmt.Fprintf(out, "Approve? [y]es / [a]lways allow %s this session / [N]o: ", req.Binary) - } else { - fmt.Fprint(out, "Approve? [y]es / [N]o: ") - } - if f, ok := out.(*os.File); ok { - _ = f.Sync() - } -} - -func parseApprovalAnswer(raw string) tools.ApprovalDecision { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "y", "yes": - return tools.ApprovalDecision{Approved: true} - case "a", "always": - return tools.ApprovalDecision{Approved: true, Always: true} - default: - return tools.ApprovalDecision{} - } -} - -func safeClose(ch chan struct{}) { - defer func() { _ = recover() }() - close(ch) -} diff --git a/internal/repl/repl_test.go b/internal/repl/repl_test.go deleted file mode 100644 index a39ad7b..0000000 --- a/internal/repl/repl_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package repl - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -// scriptedLLM returns a sequence of pre-recorded chat responses, one -// per Run() call. Used to drive the REPL through deterministic turns. -type scriptedLLM struct{ responses []llm.ChatResponse } - -func (s *scriptedLLM) Chat(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) { - if len(s.responses) == 0 { - return &llm.ChatResponse{ - Message: llm.Message{Role: llm.RoleAssistant, Content: "(out of script)"}, - FinishReason: llm.FinishStop, - }, nil - } - r := s.responses[0] - s.responses = s.responses[1:] - return &r, nil -} - -func newProjectAt(t *testing.T) (string, *projectx.Project) { - t.Helper() - wd := t.TempDir() - p, err := projectx.Init(wd, "test-proj", "Test") - if err != nil { - t.Fatalf("project init: %v", err) - } - return wd, p -} - -func TestRunExitsOnSlashExit(t *testing.T) { - wd, p := newProjectAt(t) - reg := tools.NewRegistry() - rt := &runtime.Runtime{LLM: &scriptedLLM{}, Registry: reg} - - in := strings.NewReader("/exit\n") - var out, errOut bytes.Buffer - err := Run(context.Background(), Options{ - Workdir: wd, Project: p, Runtime: rt, - SystemPrompt: "be terse", SessionIntent: "test", - In: in, Out: &out, Err: &errOut, - }) - if err != nil { - t.Fatalf("Run: %v", err) - } - if !strings.Contains(out.String(), "bye") { - t.Errorf("expected goodbye, got: %q", out.String()) - } -} - -func TestRunExitsOnEOF(t *testing.T) { - wd, p := newProjectAt(t) - reg := tools.NewRegistry() - rt := &runtime.Runtime{LLM: &scriptedLLM{}, Registry: reg} - - in := strings.NewReader("") // immediate EOF - var out, errOut bytes.Buffer - if err := Run(context.Background(), Options{ - Workdir: wd, Project: p, Runtime: rt, - SystemPrompt: "x", SessionIntent: "test", - In: in, Out: &out, Err: &errOut, - }); err != nil { - t.Fatalf("Run: %v", err) - } -} - -func TestRunSendsUserInputThroughRuntimeAndStreamsTextOut(t *testing.T) { - wd, p := newProjectAt(t) - reg := tools.NewRegistry() - rt := &runtime.Runtime{ - LLM: &scriptedLLM{responses: []llm.ChatResponse{{ - Message: llm.Message{Role: llm.RoleAssistant, Content: "hello back"}, - FinishReason: llm.FinishStop, - }}}, - Registry: reg, - } - in := strings.NewReader("hi\n/exit\n") - var out, errOut bytes.Buffer - if err := Run(context.Background(), Options{ - Workdir: wd, Project: p, Runtime: rt, - SystemPrompt: "x", SessionIntent: "test", - In: in, Out: &out, Err: &errOut, - }); err != nil { - t.Fatalf("Run: %v", err) - } - if !strings.Contains(out.String(), "hello back") { - t.Errorf("model reply not rendered: %q", out.String()) - } -} - -func TestRunPersistsHistoryAcrossTurns(t *testing.T) { - wd, p := newProjectAt(t) - reg := tools.NewRegistry() - - // Track what each Chat call sees. - var seen []int - wrapping := &recordingLLM{ - inner: &scriptedLLM{responses: []llm.ChatResponse{ - {Message: llm.Message{Role: llm.RoleAssistant, Content: "a"}, FinishReason: llm.FinishStop}, - {Message: llm.Message{Role: llm.RoleAssistant, Content: "b"}, FinishReason: llm.FinishStop}, - }}, - recorder: &seen, - } - rt := &runtime.Runtime{LLM: wrapping, Registry: reg} - - in := strings.NewReader("first\nsecond\n/exit\n") - var out, errOut bytes.Buffer - if err := Run(context.Background(), Options{ - Workdir: wd, Project: p, Runtime: rt, - SystemPrompt: "be terse", SessionIntent: "test", - In: in, Out: &out, Err: &errOut, - }); err != nil { - t.Fatalf("Run: %v", err) - } - if len(seen) != 2 { - t.Fatalf("expected 2 chat calls, got %d", len(seen)) - } - // Turn 1: system + user. Turn 2: system + user + assistant + user. - if seen[0] != 2 { - t.Errorf("first turn message count: %d (want 2)", seen[0]) - } - if seen[1] != 4 { - t.Errorf("second turn message count: %d (want 4)", seen[1]) - } -} - -func TestSlashClearResetsHistory(t *testing.T) { - wd, p := newProjectAt(t) - reg := tools.NewRegistry() - - var seen []int - wrapping := &recordingLLM{ - inner: &scriptedLLM{responses: []llm.ChatResponse{ - {Message: llm.Message{Role: llm.RoleAssistant, Content: "a"}, FinishReason: llm.FinishStop}, - {Message: llm.Message{Role: llm.RoleAssistant, Content: "b"}, FinishReason: llm.FinishStop}, - }}, - recorder: &seen, - } - rt := &runtime.Runtime{LLM: wrapping, Registry: reg} - - in := strings.NewReader("first\n/clear\nsecond\n/exit\n") - var out, errOut bytes.Buffer - if err := Run(context.Background(), Options{ - Workdir: wd, Project: p, Runtime: rt, - SystemPrompt: "x", SessionIntent: "test", - In: in, Out: &out, Err: &errOut, - }); err != nil { - t.Fatalf("Run: %v", err) - } - if len(seen) != 2 { - t.Fatalf("expected 2 chat calls, got %d", len(seen)) - } - // After /clear, the second turn should NOT include prior history — - // it sends the system prompt + user only, just like the first turn. - if seen[1] != 2 { - t.Errorf("after /clear, second turn should send 2 messages, got %d", seen[1]) - } - if !strings.Contains(out.String(), "history cleared") { - t.Errorf("expected /clear feedback, got: %q", out.String()) - } -} - -func TestSlashHelpPrintsCommandList(t *testing.T) { - wd, p := newProjectAt(t) - reg := tools.NewRegistry() - rt := &runtime.Runtime{LLM: &scriptedLLM{}, Registry: reg} - - in := strings.NewReader("/help\n/exit\n") - var out, errOut bytes.Buffer - if err := Run(context.Background(), Options{ - Workdir: wd, Project: p, Runtime: rt, - SystemPrompt: "x", SessionIntent: "test", - In: in, Out: &out, Err: &errOut, - }); err != nil { - t.Fatalf("Run: %v", err) - } - body := out.String() - for _, want := range []string{"/clear", "/history", "/save", "/session", "/exit"} { - if !strings.Contains(body, want) { - t.Errorf("/help missing %q", want) - } - } -} - -func TestRunSeedsResumedHistory(t *testing.T) { - wd, p := newProjectAt(t) - reg := tools.NewRegistry() - var seen []int - wrapping := &recordingLLM{ - inner: &scriptedLLM{responses: []llm.ChatResponse{{ - Message: llm.Message{Role: llm.RoleAssistant, Content: "continued"}, - FinishReason: llm.FinishStop, - }}}, - recorder: &seen, - } - rt := &runtime.Runtime{LLM: wrapping, Registry: reg} - initial := []llm.Message{ - {Role: llm.RoleSystem, Content: "system"}, - {Role: llm.RoleUser, Content: "before"}, - {Role: llm.RoleAssistant, Content: "old reply"}, - } - - in := strings.NewReader("next\n/exit\n") - var out, errOut bytes.Buffer - if err := Run(context.Background(), Options{ - Workdir: wd, - Project: p, - Runtime: rt, - SystemPrompt: "ignored", - SessionIntent: "test", - InitialHistory: initial, - ResumedFrom: "prev-session", - In: in, - Out: &out, - Err: &errOut, - }); err != nil { - t.Fatalf("Run: %v", err) - } - if len(seen) != 1 || seen[0] != 4 { - t.Fatalf("chat saw message counts %+v, want [4]", seen) - } - body := out.String() - for _, want := range []string{ - "resumed from: prev-session", - "loaded 3 prior messages", - "prior conversation", - "> before", - "old reply", - "continue below", - } { - if !strings.Contains(body, want) { - t.Fatalf("resume output missing %q: %q", want, body) - } - } -} - -func TestRenderHistoryShowsToolCallsAndErrors(t *testing.T) { - var out bytes.Buffer - renderHistory(&out, []llm.Message{ - {Role: llm.RoleUser, Content: "make image"}, - {Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ - Name: "generate_image", - Arguments: json.RawMessage(`{"prompt":"x"}`), - }}}, - {Role: llm.RoleTool, Content: `{"error":"image failed"}`}, - }) - - body := out.String() - for _, want := range []string{"prior conversation", "> make image", "generate_image", "error: image failed"} { - if !strings.Contains(body, want) { - t.Fatalf("history output missing %q: %q", want, body) - } - } - if strings.Contains(body, `{"error"`) { - t.Fatalf("history output should expose error text, not raw JSON: %q", body) - } -} - -func TestRenderToolCallUsesCompactSummary(t *testing.T) { - var out bytes.Buffer - renderToolCallBlock(&out, llm.ToolCall{ - Name: "generate_image", - Arguments: json.RawMessage(`{"label":"episode-2-panel-1","size":"1024x1536","prompt":"draw a consistent tennis comic with the same vertical four-panel layout and recurring character details","reference_images":["/tmp/a.png","/tmp/b.png"]}`), - }) - - body := out.String() - for _, want := range []string{"generate_image", "episode-2-panel-1", "1024x1536", "2 refs"} { - if !strings.Contains(body, want) { - t.Fatalf("tool call summary missing %q: %q", want, body) - } - } - if strings.Contains(body, `{"label"`) || strings.Contains(body, `"reference_images"`) { - t.Fatalf("tool call should not show raw JSON args: %q", body) - } -} - -func TestRenderToolResultSummarizesPath(t *testing.T) { - var out bytes.Buffer - renderToolResultBlock(&out, "generate_image", `{"path":"/tmp/openmelon/session/image.png","prompt":"long prompt","sha256":"abc"}`, nil) - - body := out.String() - if !strings.Contains(body, "saved session/image.png") { - t.Fatalf("tool result summary missing saved path: %q", body) - } - if strings.Contains(body, "sha256") || strings.Contains(body, "long prompt") { - t.Fatalf("tool result should not show noisy raw JSON: %q", body) - } -} - -func TestTerminalTracerRendersMarkdown(t *testing.T) { - var out bytes.Buffer - tr := newTerminalTracer(&out) - tr.OnText("# Plan\n\n- **First** item with `code`.\n") - tr.OnTurnEnd(1, llm.FinishStop, llm.Usage{}) - - body := out.String() - for _, want := range []string{"Plan", "- First item with code."} { - if !strings.Contains(body, want) { - t.Fatalf("markdown render missing %q: %q", want, body) - } - } - for _, raw := range []string{"# Plan", "**First**", "`code`"} { - if strings.Contains(body, raw) { - t.Fatalf("markdown render leaked raw marker %q: %q", raw, body) - } - } -} - -func TestFinishRendersAsTextNotToolCall(t *testing.T) { - var out bytes.Buffer - tr := newTerminalTracer(&out) - call := llm.ToolCall{ - ID: "finish-1", - Name: "finish", - Arguments: json.RawMessage(`{"summary":"# Done\n\n- **Ready**"}`), - } - tr.OnToolCall(call) - tr.OnToolResult(call, `{"summary":"# Done\n\n- **Ready**","artifacts":["/tmp/final.png"],"ok":true}`, nil) - - body := out.String() - if strings.Contains(body, "finish") || strings.Contains(body, "●") || strings.Contains(body, "└") { - t.Fatalf("finish should not render as a tool call: %q", body) - } - for _, want := range []string{"Done", "- Ready", "artifact: /tmp/final.png"} { - if !strings.Contains(body, want) { - t.Fatalf("finish text missing %q: %q", want, body) - } - } - if strings.Contains(body, "# Done") || strings.Contains(body, "**Ready**") { - t.Fatalf("finish summary should render markdown: %q", body) - } -} - -func TestRenderHistorySkipsSystemMessages(t *testing.T) { - var out bytes.Buffer - renderHistory(&out, []llm.Message{ - {Role: llm.RoleSystem, Content: "secret system prompt"}, - {Role: llm.RoleUser, Content: "hello"}, - }) - - body := out.String() - if strings.Contains(body, "secret system prompt") { - t.Fatalf("resume banner missing context: %q", body) - } - if !strings.Contains(body, "> hello") { - t.Fatalf("history output missing user message: %q", body) - } -} - -func TestInlineApprovalPromptAllowsAlways(t *testing.T) { - scanner := bufio.NewScanner(strings.NewReader("a\n")) - var out bytes.Buffer - approve := newApprovalPrompt(scanner, &out) - decision := approve(tools.ApprovalRequest{ - Tool: "bash", - Command: "ls -la", - Description: "inspect files", - Binary: "ls", - }) - if !decision.Approved || !decision.Always { - t.Fatalf("approval decision = %+v, want approved always", decision) - } - if !strings.Contains(out.String(), "Do you want to proceed?") { - t.Fatalf("approval output missing prompt: %q", out.String()) - } -} - -// recordingLLM wraps a ToolCaller and records the message-list length -// passed into each Chat call. -type recordingLLM struct { - inner llm.ToolCaller - recorder *[]int -} - -func (r *recordingLLM) Chat(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { - *r.recorder = append(*r.recorder, len(req.Messages)) - return r.inner.Chat(ctx, req) -} - -// satisfy json import in renderer (used by /save smoke test below) -var _ = json.RawMessage{} diff --git a/internal/repl/styles.go b/internal/repl/styles.go deleted file mode 100644 index 68f9aae..0000000 --- a/internal/repl/styles.go +++ /dev/null @@ -1,187 +0,0 @@ -package repl - -import ( - "fmt" - "io" - "path/filepath" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/session" - "golang.org/x/term" -) - -var ( - accentColor = lipgloss.Color("6") - mutedColor = lipgloss.Color("8") - whiteColor = lipgloss.Color("7") - - promptArrowStyle = lipgloss.NewStyle().Foreground(accentColor).Bold(true) - statusLineStyle = lipgloss.NewStyle().Foreground(whiteColor) - promptHintStyle = lipgloss.NewStyle().Foreground(whiteColor) - dividerStyle = lipgloss.NewStyle().Foreground(mutedColor) - mutedStyle = lipgloss.NewStyle().Foreground(mutedColor) - helpStyle = lipgloss.NewStyle().Foreground(mutedColor) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) - commandNameStyle = lipgloss.NewStyle().Foreground(accentColor) - markdownHeadingStyle = lipgloss.NewStyle().Bold(true) - markdownBoldStyle = lipgloss.NewStyle().Bold(true) - markdownLinkStyle = lipgloss.NewStyle().Underline(true) - paletteActiveStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("0")). - Background(accentColor). - Bold(true). - Padding(0, 1) - logoStyle = lipgloss.NewStyle().Foreground(accentColor).Bold(true) -) - -const rightWrapBuffer = 4 - -type bannerInfo struct { - Workdir string - Project *projectx.Project - Session *session.Session - ResumedFrom string - LLMTag string - ImageTag string - BashMode projectx.BashPermissionMode - ReasoningEffort string -} - -func printBanner(w io.Writer, info bannerInfo) { - fmt.Fprintln(w, logoStyle.Render("OpenMelon")) - fmt.Fprintln(w, statusLineStyle.Render(composeStatusLine(info))) - if info.Session != nil { - fmt.Fprintf(w, "%s\n", helpStyle.Render("session "+filepath.Base(info.Session.Dir))) - } - if info.ResumedFrom != "" { - fmt.Fprintf(w, "%s\n", helpStyle.Render("resumed from: "+info.ResumedFrom)) - } - fmt.Fprintln(w, helpStyle.Render("Type a request, /help for commands, Ctrl+C twice to quit.")) - fmt.Fprintln(w) -} - -func clearScreen(w io.Writer) { - fmt.Fprint(w, "\033[2J\033[H") -} - -func composeStatusLine(info bannerInfo) string { - parts := []string{"project"} - if info.Project != nil { - parts = append(parts, info.Project.ID) - if info.Project.Name != "" && info.Project.Name != info.Project.ID { - parts = append(parts, "("+info.Project.Name+")") - } - } - if info.LLMTag != "" { - parts = append(parts, "model "+info.LLMTag) - } - if info.ImageTag != "" { - parts = append(parts, "image "+info.ImageTag) - } - if info.ReasoningEffort != "" { - parts = append(parts, "reasoning "+info.ReasoningEffort) - } - if info.BashMode != "" { - parts = append(parts, "bash "+string(info.BashMode)) - } - if info.Workdir != "" { - parts = append(parts, filepath.Base(info.Workdir)) - } - return strings.Join(parts, " · ") -} - -func composePromptStatusLine(info bannerInfo) string { - parts := make([]string, 0, 3) - if info.LLMTag != "" { - parts = append(parts, shortModelTag(info.LLMTag)) - } - if info.ReasoningEffort != "" { - parts = append(parts, info.ReasoningEffort) - } - if info.Project != nil && info.Project.ID != "" { - parts = append(parts, info.Project.ID) - } else if info.Workdir != "" { - parts = append(parts, filepath.Base(info.Workdir)) - } - if len(parts) == 0 { - return "OpenMelon" - } - return strings.Join(parts, " · ") -} - -func shortModelTag(tag string) string { - tag = strings.TrimSpace(tag) - if tag == "" { - return "" - } - if i := strings.LastIndex(tag, ":"); i >= 0 && i+1 < len(tag) { - tag = tag[i+1:] - } - if i := strings.LastIndex(tag, "/"); i >= 0 && i+1 < len(tag) { - tag = tag[i+1:] - } - return tag -} - -func terminalWidth(w io.Writer) int { - if f, ok := w.(interface{ Fd() uintptr }); ok { - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { - return width - } - } - return 80 -} - -func isTerminalWriter(w io.Writer) bool { - f, ok := w.(interface{ Fd() uintptr }) - return ok && term.IsTerminal(int(f.Fd())) -} - -func wrappedTextWidth(w io.Writer, prefix string) int { - width := terminalWidth(w) - ansi.StringWidth(prefix) - rightWrapBuffer - if width < 12 { - return 12 - } - return width -} - -func wrapDisplayLine(line string, width int) []string { - if line == "" { - return []string{""} - } - if width < 12 { - width = 12 - } - wrapped := ansi.Wrap(line, width, "/._=&?:,") - if wrapped == "" { - return []string{""} - } - lines := strings.Split(wrapped, "\n") - indent := leadingPlainIndent(line) - if indent != "" { - cont := indent + " " - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) != "" { - lines[i] = cont + strings.TrimLeft(lines[i], " ") - } - } - } - return lines -} - -func leadingPlainIndent(s string) string { - i := 0 - for i < len(s) { - switch s[i] { - case ' ', '\t': - i++ - default: - return s[:i] - } - } - return s -} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go deleted file mode 100644 index f2459cd..0000000 --- a/internal/runtime/runtime.go +++ /dev/null @@ -1,416 +0,0 @@ -// Package runtime is openmelon's tool-driven agent loop. -// -// The loop is a small, classic ReAct-style cycle: -// -// 1. Send (system prompt, conversation, tools) to the LLM. -// 2. LLM replies with either text + tool_calls (FinishToolCalls) or -// plain text (FinishStop). -// 3. For each tool_call, dispatch via tools.Registry, append the -// result back as a tool message. -// 4. Loop until: the model finishes naturally, calls the special -// `finish` tool, or hits MaxSteps. -// -// The runtime is provider-agnostic: anything implementing llm.ToolCaller -// works. When the underlying client also implements llm.StreamingToolCaller -// the runtime uses the streaming path so the REPL can render text as it -// arrives. -package runtime - -import ( - "context" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -// Defaults applied when the caller doesn't override. -const ( - DefaultMaxSteps = 16 -) - -// Tracer receives structured events as the loop runs. -// -// Implementations render however they like — the REPL prints to a -// terminal, tests collect events into a slice, the legacy plain-stderr -// trace uses an io.Writer adapter (TraceWriter). -// -// All callbacks may be nil-safe: the runtime checks for a nil Tracer -// before calling and never panics on a partial implementation. -type Tracer interface { - OnTurnStart(turn int) - OnText(delta string) // streamed when the underlying client supports it; otherwise fired once with the full text - OnToolCall(call llm.ToolCall) - OnToolResult(call llm.ToolCall, content string, err error) - OnTurnEnd(turn int, finish llm.FinishReason, usage llm.Usage) -} - -// Runtime is the agent loop. -type Runtime struct { - LLM llm.ToolCaller - Registry *tools.Registry - - // Tracer, if non-nil, receives structured per-turn events. - Tracer Tracer - - // Hooks, if non-nil, can observe or gate model requests, model - // responses, and tool calls. Hooks are part of the agent lifecycle; - // Tracer is only presentation. - Hooks hooks.Manager - - // DrainUserInput, when non-nil, is called immediately before each - // model request after the initial user message is seeded. Returned - // strings are appended as user messages before that request. This - // lets interactive surfaces accept user corrections while a tool - // loop is running and feed them into the next model call. - DrainUserInput func() []string - - // Trace, if non-nil, receives one human-readable line per loop step. - // Legacy compatibility — kept for cmd/openmelon's headless agent - // path. Prefer Tracer for new code. - Trace io.Writer - - // MaxSteps caps how many model+tool round-trips the loop will run - // before giving up. 0 → DefaultMaxSteps. - MaxSteps int - - // ReasoningEffort is passed through to providers that expose a - // thinking-depth knob. Empty means the provider/model default. - ReasoningEffort string -} - -// RunInput is one end-to-end agent run. -type RunInput struct { - // SystemPrompt sets the agent's behavior + project context. Sent - // only when History is empty (otherwise the system prompt already - // lives at History[0]). - SystemPrompt string - - // UserInput is the user's request for this run. Always appended - // after History. - UserInput string - - // History is the prior conversation, including any tool messages. - // Pass back RunResult.Messages from a previous Run to continue - // where you left off — that's how the REPL implements multi-turn. - // When non-empty, SystemPrompt is ignored (the system message is - // assumed to already be at History[0]). - History []llm.Message - - // Temperature overrides the model's default. 0 → vendor default. - Temperature float64 - - // MaxTokens caps each turn's reply. 0 → vendor default. - MaxTokens int -} - -// RunResult summarizes one loop run. -type RunResult struct { - // Messages is the full conversation history, including all tool - // calls + tool replies. Pass back as RunInput.History to continue. - Messages []llm.Message - - // Steps is the number of LLM round-trips taken in this Run call - // (NOT cumulative across continuations). - Steps int - - // Finished is true when the loop exited via `finish` or - // FinishStop. False means MaxSteps cap or loop error. - Finished bool - - // FinishSummary is set when the loop exited via the `finish` - // tool — that tool's "summary" argument. - FinishSummary string - - // FinishArtifacts is set similarly — paths reported by `finish`. - FinishArtifacts []string -} - -// Run drives the loop end-to-end. -func (r *Runtime) Run(ctx context.Context, in RunInput) (*RunResult, error) { - if r.LLM == nil { - return nil, fmt.Errorf("runtime: LLM is required") - } - if r.Registry == nil { - return nil, fmt.Errorf("runtime: Registry is required") - } - maxSteps := r.MaxSteps - if maxSteps <= 0 { - maxSteps = DefaultMaxSteps - } - - specs := r.Registry.Specs() - wireTools := make([]llm.Tool, 0, len(specs)) - for _, s := range specs { - wireTools = append(wireTools, llm.Tool{ - Name: s.Name, - Description: s.Description, - Parameters: s.Parameters, - }) - } - - // Seed the message list. New conversations get system + user; - // continuations get history + user. - var messages []llm.Message - if len(in.History) > 0 { - messages = append(messages, in.History...) - } else if in.SystemPrompt != "" { - messages = append(messages, llm.Message{Role: llm.RoleSystem, Content: in.SystemPrompt}) - } - if in.UserInput != "" { - messages = append(messages, llm.Message{Role: llm.RoleUser, Content: in.UserInput}) - } - - // Detect streaming support. - streamer, _ := r.LLM.(llm.StreamingToolCaller) - - out := &RunResult{} - for step := 0; step < maxSteps; step++ { - out.Steps = step + 1 - messages = appendDrainedUserInput(messages, r.DrainUserInput) - r.onTurnStart(step + 1) - hr := r.beforeModelRequest(ctx, step+1, messages, wireTools) - switch hr.EffectiveDecision() { - case hooks.Deny, hooks.Cancel: - out.Messages = messages - return out, fmt.Errorf("runtime: model request blocked by hook: %s", hr.Reason) - } - messages = appendUserFeedback(messages, hr.AppendUserFeedback) - req := llm.ChatRequest{ - Messages: messages, - Tools: wireTools, - Temperature: in.Temperature, - MaxTokens: in.MaxTokens, - ReasoningEffort: r.ReasoningEffort, - } - - var resp *llm.ChatResponse - var err error - if streamer != nil { - resp, err = streamer.StreamChat(ctx, req, llm.StreamChatHandler{ - OnText: func(d string) { r.onText(d) }, - }) - } else { - resp, err = r.LLM.Chat(ctx, req) - if err == nil && resp.Message.Content != "" { - // Fire OnText once with the full body so non-streaming - // callers still see the model's reply. - r.onText(resp.Message.Content) - } - } - if err != nil { - return out, fmt.Errorf("runtime: chat (step %d): %w", step+1, err) - } - messages = append(messages, resp.Message) - var pendingHookFeedback []string - hr = r.afterModelResponse(ctx, step+1, resp) - switch hr.EffectiveDecision() { - case hooks.Deny, hooks.Cancel: - out.Messages = messages - return out, fmt.Errorf("runtime: model response blocked by hook: %s", hr.Reason) - } - pendingHookFeedback = append(pendingHookFeedback, hr.AppendUserFeedback...) - r.legacyTracef("[turn %d] reply (finish=%s, tool_calls=%d)", step+1, resp.FinishReason, len(resp.Message.ToolCalls)) - - if len(resp.Message.ToolCalls) == 0 { - messages = appendUserFeedback(messages, pendingHookFeedback) - r.onTurnEnd(step+1, resp.FinishReason, resp.Usage) - out.Messages = messages - out.Finished = resp.FinishReason == llm.FinishStop || resp.FinishReason == llm.FinishOther - return out, nil - } - - // Dispatch each tool call and append the result. - var hitFinish bool - for _, tc := range resp.Message.ToolCalls { - var res any - var dispatchErr error - hr := r.beforeToolCall(ctx, step+1, tc) - if len(hr.RewriteToolArguments) > 0 { - tc.Arguments = hr.RewriteToolArguments - } - r.onToolCall(tc) - r.legacyTracef("[turn %d] → %s(%s)", step+1, tc.Name, truncate(string(tc.Arguments), 240)) - if hr.EffectiveDecision() == hooks.Deny || hr.EffectiveDecision() == hooks.Cancel { - dispatchErr = fmt.Errorf("blocked by hook: %s", hr.Reason) - } else { - res, dispatchErr = r.Registry.Dispatch(ctx, tc.Name, tc.Arguments) - } - var content string - switch { - case dispatchErr != nil: - b, _ := json.Marshal(map[string]string{"error": dispatchErr.Error()}) - content = string(b) - default: - b, mErr := json.Marshal(res) - if mErr != nil { - b, _ = json.Marshal(map[string]string{"error": "tool result not serializable: " + mErr.Error()}) - } - content = string(b) - dispatchErr = toolContentError(content) - } - hr = r.afterToolCall(ctx, step+1, tc, content, dispatchErr) - pendingHookFeedback = append(pendingHookFeedback, hr.AppendUserFeedback...) - r.onToolResult(tc, content, dispatchErr) - r.legacyTracef("[turn %d] ← %s", step+1, truncate(content, 240)) - messages = append(messages, llm.Message{ - Role: llm.RoleTool, - ToolCallID: tc.ID, - Content: content, - }) - - if tc.Name == "finish" && dispatchErr == nil { - if m, ok := res.(map[string]any); ok { - if s, _ := m["summary"].(string); s != "" { - out.FinishSummary = s - } - if arts, ok := m["artifacts"].([]string); ok { - out.FinishArtifacts = arts - } - } - hitFinish = true - } - } - messages = appendUserFeedback(messages, pendingHookFeedback) - r.onTurnEnd(step+1, resp.FinishReason, resp.Usage) - if hitFinish { - out.Messages = messages - out.Finished = true - return out, nil - } - } - - out.Messages = messages - return out, fmt.Errorf("runtime: hit MaxSteps=%d without finishing", maxSteps) -} - -func appendDrainedUserInput(messages []llm.Message, drain func() []string) []llm.Message { - if drain == nil { - return messages - } - for _, text := range drain() { - text = strings.TrimSpace(text) - if text == "" { - continue - } - messages = append(messages, llm.Message{Role: llm.RoleUser, Content: text}) - } - return messages -} - -func appendUserFeedback(messages []llm.Message, feedback []string) []llm.Message { - for _, text := range feedback { - text = strings.TrimSpace(text) - if text == "" { - continue - } - messages = append(messages, llm.Message{Role: llm.RoleUser, Content: text}) - } - return messages -} - -// --- tracer + legacy-writer fan-out --- - -func (r *Runtime) onTurnStart(turn int) { - if r.Tracer != nil { - r.Tracer.OnTurnStart(turn) - } -} - -func (r *Runtime) onText(delta string) { - if r.Tracer != nil { - r.Tracer.OnText(delta) - } -} - -func (r *Runtime) onToolCall(tc llm.ToolCall) { - if r.Tracer != nil { - r.Tracer.OnToolCall(tc) - } -} - -func (r *Runtime) onToolResult(tc llm.ToolCall, content string, err error) { - if r.Tracer != nil { - r.Tracer.OnToolResult(tc, content, err) - } -} - -func toolContentError(content string) error { - var obj map[string]any - if err := json.Unmarshal([]byte(content), &obj); err != nil { - return nil - } - raw, ok := obj["error"] - if !ok || raw == nil { - return nil - } - switch v := raw.(type) { - case string: - if strings.TrimSpace(v) == "" { - return nil - } - return fmt.Errorf("%s", v) - default: - b, err := json.Marshal(v) - if err != nil { - return fmt.Errorf("%v", v) - } - return fmt.Errorf("%s", string(b)) - } -} - -func (r *Runtime) onTurnEnd(turn int, finish llm.FinishReason, usage llm.Usage) { - if r.Tracer != nil { - r.Tracer.OnTurnEnd(turn, finish, usage) - } -} - -func (r *Runtime) beforeModelRequest(ctx context.Context, step int, messages []llm.Message, tools []llm.Tool) hooks.HookResult { - if r.Hooks == nil { - return hooks.HookResult{} - } - return r.Hooks.BeforeModelRequest(ctx, hooks.ModelRequestEvent{ - Step: step, - Messages: append([]llm.Message(nil), messages...), - Tools: append([]llm.Tool(nil), tools...), - }) -} - -func (r *Runtime) afterModelResponse(ctx context.Context, step int, resp *llm.ChatResponse) hooks.HookResult { - if r.Hooks == nil || resp == nil { - return hooks.HookResult{} - } - return r.Hooks.AfterModelResponse(ctx, hooks.ModelResponseEvent{Step: step, Response: *resp}) -} - -func (r *Runtime) beforeToolCall(ctx context.Context, step int, tc llm.ToolCall) hooks.HookResult { - if r.Hooks == nil { - return hooks.HookResult{} - } - return r.Hooks.BeforeToolCall(ctx, hooks.ToolCallEvent{Step: step, Call: tc}) -} - -func (r *Runtime) afterToolCall(ctx context.Context, step int, tc llm.ToolCall, content string, err error) hooks.HookResult { - if r.Hooks == nil { - return hooks.HookResult{} - } - return r.Hooks.AfterToolCall(ctx, hooks.ToolResultEvent{Step: step, Call: tc, Content: content, Err: err}) -} - -func (r *Runtime) legacyTracef(format string, args ...any) { - if r.Trace == nil { - return - } - fmt.Fprintf(r.Trace, format+"\n", args...) -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go deleted file mode 100644 index b553494..0000000 --- a/internal/runtime/runtime_test.go +++ /dev/null @@ -1,589 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -// fakeLLM is a scripted ToolCaller for tests. Each call to Chat returns -// the next pre-recorded response. If we run out of responses, the test -// fails. -type fakeLLM struct { - t *testing.T - responses []llm.ChatResponse - calls int - lastReq llm.ChatRequest - requests []llm.ChatRequest -} - -func (f *fakeLLM) Chat(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { - if f.calls >= len(f.responses) { - f.t.Fatalf("fakeLLM ran out of responses after %d calls", f.calls) - } - f.lastReq = req - f.requests = append(f.requests, req) - r := f.responses[f.calls] - f.calls++ - return &r, nil -} - -func TestRunStopsImmediatelyWhenModelHasNoToolCalls(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "noop", - Description: "no-op", - Parameters: json.RawMessage(`{"type":"object"}`), - }, - Handler: func(_ context.Context, _ json.RawMessage) (any, error) { return "ok", nil }, - }) - - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{{ - Message: llm.Message{Role: llm.RoleAssistant, Content: "all done"}, - FinishReason: llm.FinishStop, - }}} - - rt := &Runtime{LLM: llmFake, Registry: reg} - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "be terse", UserInput: "hi"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if !res.Finished { - t.Errorf("expected Finished=true") - } - if res.Steps != 1 { - t.Errorf("expected 1 step, got %d", res.Steps) - } - // Tools were forwarded to the model. - if len(llmFake.lastReq.Tools) != 1 || llmFake.lastReq.Tools[0].Name != "noop" { - t.Errorf("tools not forwarded: %+v", llmFake.lastReq.Tools) - } -} - -func TestRunDispatchesToolCallsAndFeedsResultsBack(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "echo", - Description: "echo", - Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}}}`), - }, - Handler: func(_ context.Context, raw json.RawMessage) (any, error) { - var args struct{ Text string } - _ = json.Unmarshal(raw, &args) - return map[string]any{"echoed": args.Text}, nil - }, - }) - - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{ - { - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ - ID: "call-1", Name: "echo", - Arguments: json.RawMessage(`{"text":"hello"}`), - }}, - }, - FinishReason: llm.FinishToolCalls, - }, - { - Message: llm.Message{Role: llm.RoleAssistant, Content: "got it"}, - FinishReason: llm.FinishStop, - }, - }} - - rt := &Runtime{LLM: llmFake, Registry: reg} - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if res.Steps != 2 { - t.Errorf("expected 2 steps, got %d", res.Steps) - } - - // Conversation should be: system, user, assistant(tool_call), tool, assistant(stop) - if len(res.Messages) != 5 { - t.Fatalf("expected 5 messages, got %d: %+v", len(res.Messages), res.Messages) - } - if res.Messages[3].Role != llm.RoleTool || res.Messages[3].ToolCallID != "call-1" { - t.Errorf("tool reply mismatched: %+v", res.Messages[3]) - } - if !strings.Contains(res.Messages[3].Content, `"echoed":"hello"`) { - t.Errorf("tool reply content: %q", res.Messages[3].Content) - } -} - -func TestRunDrainsUserInputBeforeEachModelCall(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "echo", - Description: "echo", - Parameters: json.RawMessage(`{"type":"object"}`), - }, - Handler: func(_ context.Context, _ json.RawMessage) (any, error) { - return map[string]any{"ok": true}, nil - }, - }) - - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{ - { - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ID: "call-1", Name: "echo", Arguments: json.RawMessage(`{}`)}}, - }, - FinishReason: llm.FinishToolCalls, - }, - { - Message: llm.Message{Role: llm.RoleAssistant, Content: "updated"}, - FinishReason: llm.FinishStop, - }, - }} - - drains := 0 - rt := &Runtime{ - LLM: llmFake, - Registry: reg, - DrainUserInput: func() []string { - drains++ - if drains == 1 { - return []string{"First queued context."} - } - if drains == 2 { - return []string{"Actually, make it shorter."} - } - return nil - }, - } - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(llmFake.requests) != 2 { - t.Fatalf("expected 2 model requests, got %d", len(llmFake.requests)) - } - first := llmFake.requests[0].Messages - if got := first[len(first)-1]; got.Role != llm.RoleUser || got.Content != "First queued context." { - t.Fatalf("first drained input not appended before first model call: %+v", got) - } - second := llmFake.requests[1].Messages - if len(second) < 5 { - t.Fatalf("second request too short: %+v", second) - } - got := second[len(second)-1] - if got.Role != llm.RoleUser || got.Content != "Actually, make it shorter." { - t.Fatalf("drained input not appended before second model call: %+v", got) - } - if len(res.Messages) == 0 || res.Messages[len(res.Messages)-2].Content != "Actually, make it shorter." { - t.Fatalf("result history missing drained input: %+v", res.Messages) - } -} - -func TestRunSurfacesToolErrorAsContentSoModelCanRecover(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "boom", Description: "x", - Parameters: json.RawMessage(`{"type":"object"}`), - }, - Handler: func(_ context.Context, _ json.RawMessage) (any, error) { - return nil, errFake("explicit failure") - }, - }) - - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{ - { - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ID: "x", Name: "boom", Arguments: json.RawMessage(`{}`)}}, - }, - FinishReason: llm.FinishToolCalls, - }, - { - Message: llm.Message{Role: llm.RoleAssistant, Content: "stopping"}, - FinishReason: llm.FinishStop, - }, - }} - - rt := &Runtime{LLM: llmFake, Registry: reg} - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - // The tool error must reach the model as a JSON tool message. - toolMsg := res.Messages[3] - if toolMsg.Role != llm.RoleTool { - t.Fatalf("expected tool message at [3], got %+v", toolMsg) - } - if !strings.Contains(toolMsg.Content, "explicit failure") { - t.Errorf("tool error not surfaced: %q", toolMsg.Content) - } -} - -func TestRunMarksToolResultErrorFieldAsFailure(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "soft_fail", Description: "x", - Parameters: json.RawMessage(`{"type":"object"}`), - }, - Handler: func(_ context.Context, _ json.RawMessage) (any, error) { - return map[string]any{"error": "provider rejected request"}, nil - }, - }) - - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{ - { - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ID: "x", Name: "soft_fail", Arguments: json.RawMessage(`{}`)}}, - }, - FinishReason: llm.FinishToolCalls, - }, - { - Message: llm.Message{Role: llm.RoleAssistant, Content: "recovered"}, - FinishReason: llm.FinishStop, - }, - }} - tracer := &captureTracer{} - var hookErr error - rt := &Runtime{ - LLM: llmFake, - Registry: reg, - Tracer: tracer, - Hooks: &scriptedHooks{ - afterTool: func(_ context.Context, e hooks.ToolResultEvent) hooks.HookResult { - hookErr = e.Err - return hooks.HookResult{} - }, - }, - } - - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if !strings.Contains(res.Messages[3].Content, "provider rejected request") { - t.Fatalf("tool error content not preserved: %q", res.Messages[3].Content) - } - if hookErr == nil || !strings.Contains(hookErr.Error(), "provider rejected request") { - t.Fatalf("hook did not receive tool error: %v", hookErr) - } - if len(tracer.errors) == 0 || tracer.errors[0] == nil || !strings.Contains(tracer.errors[0].Error(), "provider rejected request") { - t.Fatalf("tracer did not receive tool error: %#v", tracer.errors) - } -} - -func TestRunLifecycleHooksCanRewriteAndDenyToolCalls(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "echo", - Description: "echo", - Parameters: json.RawMessage(`{"type":"object"}`), - }, - Handler: func(_ context.Context, raw json.RawMessage) (any, error) { - var args struct{ Text string } - _ = json.Unmarshal(raw, &args) - return map[string]any{"echoed": args.Text}, nil - }, - }) - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{ - { - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ID: "call-1", Name: "echo", Arguments: json.RawMessage(`{"text":"original"}`)}}, - }, - FinishReason: llm.FinishToolCalls, - }, - { - Message: llm.Message{Role: llm.RoleAssistant, Content: "done"}, - FinishReason: llm.FinishStop, - }, - }} - h := &scriptedHooks{ - beforeTool: func(_ context.Context, e hooks.ToolCallEvent) hooks.HookResult { - return hooks.HookResult{RewriteToolArguments: json.RawMessage(`{"text":"rewritten"}`)} - }, - afterTool: func(_ context.Context, e hooks.ToolResultEvent) hooks.HookResult { - return hooks.HookResult{AppendUserFeedback: []string{"hook feedback"}} - }, - } - rt := &Runtime{LLM: llmFake, Registry: reg, Hooks: h} - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if !strings.Contains(res.Messages[3].Content, "rewritten") { - t.Fatalf("tool args were not rewritten: %+v", res.Messages[3]) - } - second := llmFake.requests[1].Messages - if got := second[len(second)-1]; got.Role != llm.RoleUser || got.Content != "hook feedback" { - t.Fatalf("hook feedback not appended before next model call: %+v", got) - } -} - -func TestRunHookDenyToolCallReturnsToolError(t *testing.T) { - reg := tools.NewRegistry() - called := false - reg.Register(tools.Tool{ - Spec: tools.Spec{Name: "echo", Description: "echo", Parameters: json.RawMessage(`{"type":"object"}`)}, - Handler: func(_ context.Context, _ json.RawMessage) (any, error) { - called = true - return nil, nil - }, - }) - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{ - { - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ID: "call-1", Name: "echo", Arguments: json.RawMessage(`{}`)}}, - }, - FinishReason: llm.FinishToolCalls, - }, - { - Message: llm.Message{Role: llm.RoleAssistant, Content: "done"}, - FinishReason: llm.FinishStop, - }, - }} - rt := &Runtime{ - LLM: llmFake, - Registry: reg, - Hooks: &scriptedHooks{beforeTool: func(_ context.Context, e hooks.ToolCallEvent) hooks.HookResult { - return hooks.HookResult{Decision: hooks.Deny, Reason: "not allowed"} - }}, - } - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if called { - t.Fatal("denied tool handler was called") - } - if !strings.Contains(res.Messages[3].Content, "not allowed") { - t.Fatalf("denial not returned as tool content: %+v", res.Messages[3]) - } -} - -func TestRunStopsOnFinishTool(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "finish", Description: "done", - Parameters: json.RawMessage(`{"type":"object","properties":{"summary":{"type":"string"}}}`), - }, - Handler: func(_ context.Context, raw json.RawMessage) (any, error) { - var a struct{ Summary string } - _ = json.Unmarshal(raw, &a) - return map[string]any{"summary": a.Summary, "ok": true}, nil - }, - }) - - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{{ - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ - ID: "f", Name: "finish", Arguments: json.RawMessage(`{"summary":"all done"}`), - }}, - }, - FinishReason: llm.FinishToolCalls, - }}} - - rt := &Runtime{LLM: llmFake, Registry: reg} - res, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if !res.Finished { - t.Error("expected Finished=true") - } - if res.FinishSummary != "all done" { - t.Errorf("summary: %q", res.FinishSummary) - } - // Loop did not run a second LLM turn after finish. - if llmFake.calls != 1 { - t.Errorf("expected 1 LLM call, got %d", llmFake.calls) - } -} - -func TestRunReturnsErrorWhenMaxStepsExceeded(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "loop", Description: "x", - Parameters: json.RawMessage(`{"type":"object"}`), - }, - Handler: func(_ context.Context, _ json.RawMessage) (any, error) { return "ok", nil }, - }) - - // Always return tool_calls — the model never finishes. - llmFake := &fakeLLM{t: t} - for i := 0; i < 5; i++ { - llmFake.responses = append(llmFake.responses, llm.ChatResponse{ - Message: llm.Message{ - Role: llm.RoleAssistant, - ToolCalls: []llm.ToolCall{{ - ID: "x", Name: "loop", Arguments: json.RawMessage(`{}`), - }}, - }, - FinishReason: llm.FinishToolCalls, - }) - } - - rt := &Runtime{LLM: llmFake, Registry: reg, MaxSteps: 3} - _, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}) - if err == nil || !strings.Contains(err.Error(), "MaxSteps") { - t.Errorf("expected MaxSteps error, got %v", err) - } -} - -type errFake string - -func (e errFake) Error() string { return string(e) } - -type scriptedHooks struct { - hooks.NoopManager - beforeModel func(context.Context, hooks.ModelRequestEvent) hooks.HookResult - afterModel func(context.Context, hooks.ModelResponseEvent) hooks.HookResult - beforeTool func(context.Context, hooks.ToolCallEvent) hooks.HookResult - afterTool func(context.Context, hooks.ToolResultEvent) hooks.HookResult -} - -func (h *scriptedHooks) BeforeModelRequest(ctx context.Context, e hooks.ModelRequestEvent) hooks.HookResult { - if h.beforeModel != nil { - return h.beforeModel(ctx, e) - } - return hooks.HookResult{} -} - -func (h *scriptedHooks) AfterModelResponse(ctx context.Context, e hooks.ModelResponseEvent) hooks.HookResult { - if h.afterModel != nil { - return h.afterModel(ctx, e) - } - return hooks.HookResult{} -} - -func (h *scriptedHooks) BeforeToolCall(ctx context.Context, e hooks.ToolCallEvent) hooks.HookResult { - if h.beforeTool != nil { - return h.beforeTool(ctx, e) - } - return hooks.HookResult{} -} - -func (h *scriptedHooks) AfterToolCall(ctx context.Context, e hooks.ToolResultEvent) hooks.HookResult { - if h.afterTool != nil { - return h.afterTool(ctx, e) - } - return hooks.HookResult{} -} - -func TestRunWithHistoryAppendsUserAndPreservesPriorMessages(t *testing.T) { - reg := tools.NewRegistry() - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{{ - Message: llm.Message{Role: llm.RoleAssistant, Content: "ack"}, - FinishReason: llm.FinishStop, - }}} - - prior := []llm.Message{ - {Role: llm.RoleSystem, Content: "be terse"}, - {Role: llm.RoleUser, Content: "first"}, - {Role: llm.RoleAssistant, Content: "first reply"}, - } - rt := &Runtime{LLM: llmFake, Registry: reg} - res, err := rt.Run(context.Background(), RunInput{ - History: prior, - UserInput: "second", - }) - if err != nil { - t.Fatalf("Run: %v", err) - } - // Wire request must include all 3 prior + the new user message. - if len(llmFake.lastReq.Messages) != 4 { - t.Fatalf("expected 4 messages sent, got %d", len(llmFake.lastReq.Messages)) - } - if llmFake.lastReq.Messages[3].Content != "second" || llmFake.lastReq.Messages[3].Role != llm.RoleUser { - t.Errorf("user message not appended: %+v", llmFake.lastReq.Messages[3]) - } - // SystemPrompt must NOT be re-injected when History is non-empty. - if llmFake.lastReq.Messages[0].Content != "be terse" { - t.Errorf("system prompt clobbered: %+v", llmFake.lastReq.Messages[0]) - } - // Final result includes the new user + new assistant. - if len(res.Messages) != 5 { - t.Errorf("expected 5 result messages, got %d", len(res.Messages)) - } -} - -// captureTracer is a Tracer that records every event for assertions. -type captureTracer struct { - turns []int - textDeltas []string - calls []llm.ToolCall - results []string - errors []error -} - -func (c *captureTracer) OnTurnStart(turn int) { c.turns = append(c.turns, turn) } -func (c *captureTracer) OnText(d string) { c.textDeltas = append(c.textDeltas, d) } -func (c *captureTracer) OnToolCall(tc llm.ToolCall) { c.calls = append(c.calls, tc) } -func (c *captureTracer) OnToolResult(tc llm.ToolCall, content string, err error) { - c.results = append(c.results, content) - c.errors = append(c.errors, err) -} -func (c *captureTracer) OnTurnEnd(turn int, _ llm.FinishReason, _ llm.Usage) {} - -func TestTracerReceivesAllEvents(t *testing.T) { - reg := tools.NewRegistry() - reg.Register(tools.Tool{ - Spec: tools.Spec{ - Name: "echo", Description: "x", - Parameters: json.RawMessage(`{"type":"object"}`), - }, - Handler: func(_ context.Context, _ json.RawMessage) (any, error) { - return map[string]any{"ok": true}, nil - }, - }) - - llmFake := &fakeLLM{t: t, responses: []llm.ChatResponse{ - { - Message: llm.Message{ - Role: llm.RoleAssistant, - Content: "thinking...", - ToolCalls: []llm.ToolCall{{ID: "x", Name: "echo", Arguments: json.RawMessage(`{}`)}}, - }, - FinishReason: llm.FinishToolCalls, - }, - { - Message: llm.Message{Role: llm.RoleAssistant, Content: "all done"}, - FinishReason: llm.FinishStop, - }, - }} - - tr := &captureTracer{} - rt := &Runtime{LLM: llmFake, Registry: reg, Tracer: tr} - if _, err := rt.Run(context.Background(), RunInput{SystemPrompt: "x", UserInput: "go"}); err != nil { - t.Fatalf("Run: %v", err) - } - if len(tr.turns) != 2 || tr.turns[0] != 1 || tr.turns[1] != 2 { - t.Errorf("turns: %v", tr.turns) - } - // Non-streaming fakeLLM still fires OnText once per non-empty turn. - if !strings.Contains(strings.Join(tr.textDeltas, ""), "thinking...") { - t.Errorf("text deltas missing 'thinking...': %v", tr.textDeltas) - } - if !strings.Contains(strings.Join(tr.textDeltas, ""), "all done") { - t.Errorf("text deltas missing 'all done': %v", tr.textDeltas) - } - if len(tr.calls) != 1 || tr.calls[0].Name != "echo" { - t.Errorf("tool calls: %+v", tr.calls) - } - if len(tr.results) != 1 || !strings.Contains(tr.results[0], `"ok":true`) { - t.Errorf("tool results: %+v", tr.results) - } -} diff --git a/internal/search/search.go b/internal/search/search.go deleted file mode 100644 index cab732f..0000000 --- a/internal/search/search.go +++ /dev/null @@ -1,194 +0,0 @@ -// Package search is openmelon's grep-style search across all on-disk -// content libraries (characters, references, materials). -// -// Design intent: deliberately not vector. The corpus is small (hundreds -// of items per project, not millions), the queries are operator-style -// (tags + substring), and a fresh-pull grep over the registry is fast -// enough that adding an index server / embedding model isn't justified. -// -// The query language is intentionally tiny: -// -// bare token substring match in name OR description (case-insensitive) -// tag:foo require tag "foo" exactly -// kind:character restrict to one Kind -// -token negative substring match -// -// Multiple terms are AND'd. Order doesn't matter. Tokens with internal -// whitespace must be quoted with double quotes. -// -// Result ranking is "items with the most positive substring hits in -// description first, then alphabetic by slug" — no TF/IDF, no vectors. -package search - -import ( - "fmt" - "sort" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/registry" -) - -// Hit is one search result. -type Hit struct { - Item *registry.Item - Score int // higher = better; ties broken by slug -} - -// Query holds a parsed query. -type Query struct { - Substrings []string // positive substring matches (case-insensitive) - Negatives []string // negative substring matches - Tags []string // required tags (exact match) - Kinds []registry.Kind // restrict to these kinds; empty = all -} - -// Parse turns a raw query string into a Query. -// -// Tokens are space-separated; double-quoted spans count as one token. -// Recognized prefixes: -// -// tag: require tag -// kind: restrict to ; legal: character|reference|material -// - exclude items containing -// require items containing in name or description -func Parse(raw string) (*Query, error) { - tokens, err := tokenize(raw) - if err != nil { - return nil, err - } - q := &Query{} - for _, t := range tokens { - if t == "" { - continue - } - switch { - case strings.HasPrefix(t, "tag:"): - val := strings.ToLower(strings.TrimPrefix(t, "tag:")) - if val != "" { - q.Tags = append(q.Tags, val) - } - case strings.HasPrefix(t, "kind:"): - val := strings.ToLower(strings.TrimPrefix(t, "kind:")) - switch val { - case "character", "characters": - q.Kinds = append(q.Kinds, registry.KindCharacter) - case "reference", "references", "ref", "refs": - q.Kinds = append(q.Kinds, registry.KindReference) - case "material", "materials": - q.Kinds = append(q.Kinds, registry.KindMaterial) - default: - return nil, fmt.Errorf("search: unknown kind: %q", val) - } - case strings.HasPrefix(t, "-") && len(t) > 1: - q.Negatives = append(q.Negatives, strings.ToLower(t[1:])) - default: - q.Substrings = append(q.Substrings, strings.ToLower(t)) - } - } - return q, nil -} - -// Run executes a parsed query against a project's registry. -func Run(workdir string, q *Query) ([]Hit, error) { - kinds := q.Kinds - if len(kinds) == 0 { - kinds = []registry.Kind{registry.KindCharacter, registry.KindReference, registry.KindMaterial} - } - var hits []Hit - for _, kind := range kinds { - items, err := registry.List(workdir, kind) - if err != nil { - return nil, err - } - for _, item := range items { - if hit, ok := score(item, q); ok { - hits = append(hits, hit) - } - } - } - sort.SliceStable(hits, func(i, j int) bool { - if hits[i].Score != hits[j].Score { - return hits[i].Score > hits[j].Score - } - if hits[i].Item.Kind != hits[j].Item.Kind { - return hits[i].Item.Kind < hits[j].Item.Kind - } - return hits[i].Item.Slug < hits[j].Item.Slug - }) - return hits, nil -} - -// score evaluates one item against the query. Returns (Hit, true) if the -// item matches; (Hit{}, false) otherwise. -func score(item *registry.Item, q *Query) (Hit, bool) { - hay := strings.ToLower(item.Name + "\n" + item.Description) - for _, neg := range q.Negatives { - if strings.Contains(hay, neg) { - return Hit{}, false - } - } - for _, want := range q.Tags { - ok := false - for _, have := range item.Tags { - if strings.EqualFold(have, want) { - ok = true - break - } - } - if !ok { - return Hit{}, false - } - } - score := 0 - for _, sub := range q.Substrings { - // A description hit is worth more than a name hit because - // descriptions are longer and more specific. Tag hits get a - // small bonus too — if you typed "vendor" and a tag literally - // is "vendor", that's a strong match. - hits := strings.Count(hay, sub) - if hits == 0 { - return Hit{}, false - } - score += hits - for _, t := range item.Tags { - if strings.Contains(strings.ToLower(t), sub) { - score += 2 - } - } - } - if len(q.Substrings) == 0 && len(q.Tags) == 0 { - // Empty query (after stripping kind:/negatives) matches all - // items in the chosen kinds, with score 0. - score = 0 - } - return Hit{Item: item, Score: score}, true -} - -// tokenize splits raw on whitespace, honoring "double-quoted" spans. -func tokenize(raw string) ([]string, error) { - var tokens []string - var b strings.Builder - inQuote := false - for i := 0; i < len(raw); i++ { - c := raw[i] - if c == '"' { - inQuote = !inQuote - continue - } - if !inQuote && (c == ' ' || c == '\t' || c == '\n') { - if b.Len() > 0 { - tokens = append(tokens, b.String()) - b.Reset() - } - continue - } - b.WriteByte(c) - } - if inQuote { - return nil, fmt.Errorf("search: unbalanced quote in query") - } - if b.Len() > 0 { - tokens = append(tokens, b.String()) - } - return tokens, nil -} diff --git a/internal/search/search_test.go b/internal/search/search_test.go deleted file mode 100644 index 822a3cd..0000000 --- a/internal/search/search_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package search - -import ( - "testing" - - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/registry" -) - -func mustInit(t *testing.T) string { - t.Helper() - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - return wd -} - -func mustAdd(t *testing.T, wd string, opts registry.AddOptions) { - t.Helper() - if _, err := registry.Add(wd, opts); err != nil { - t.Fatalf("registry add %s/%s: %v", opts.Kind, opts.Slug, err) - } -} - -func seed(t *testing.T) string { - wd := mustInit(t) - mustAdd(t, wd, registry.AddOptions{ - Kind: registry.KindCharacter, Slug: "lao-wang", Name: "Lao Wang", - Description: "Mid-50s street vendor with a quiet smile.", - Tags: []string{"character", "vendor", "elder"}, - }) - mustAdd(t, wd, registry.AddOptions{ - Kind: registry.KindCharacter, Slug: "xiao-li", Name: "Xiao Li", - Description: "Young photographer documenting the night market.", - Tags: []string{"character", "photographer", "young"}, - }) - mustAdd(t, wd, registry.AddOptions{ - Kind: registry.KindReference, Slug: "kitchen-night", Name: "Kitchen at night", - Description: "Warm-tone neon kitchen at 22:00, steam from a wok.", - Tags: []string{"scene", "kitchen", "night"}, - }) - return wd -} - -func TestParseExtractsAllOperators(t *testing.T) { - q, err := Parse(`vendor tag:character kind:character -photographer "night market"`) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(q.Substrings) != 2 || q.Substrings[0] != "vendor" || q.Substrings[1] != "night market" { - t.Errorf("substrings: %v", q.Substrings) - } - if len(q.Tags) != 1 || q.Tags[0] != "character" { - t.Errorf("tags: %v", q.Tags) - } - if len(q.Kinds) != 1 || q.Kinds[0] != registry.KindCharacter { - t.Errorf("kinds: %v", q.Kinds) - } - if len(q.Negatives) != 1 || q.Negatives[0] != "photographer" { - t.Errorf("negatives: %v", q.Negatives) - } -} - -func TestParseRejectsUnbalancedQuote(t *testing.T) { - if _, err := Parse(`foo "open`); err == nil { - t.Fatal("expected error") - } -} - -func TestParseRejectsUnknownKind(t *testing.T) { - if _, err := Parse(`kind:widget`); err == nil { - t.Fatal("expected error for unknown kind") - } -} - -func TestRunSubstringMatchesNameOrDescription(t *testing.T) { - wd := seed(t) - q, _ := Parse(`vendor`) - hits, err := Run(wd, q) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(hits) != 1 || hits[0].Item.Slug != "lao-wang" { - t.Errorf("unexpected hits: %+v", hits) - } -} - -func TestRunTagFilterMustMatchExactly(t *testing.T) { - wd := seed(t) - q, _ := Parse(`tag:photographer`) - hits, err := Run(wd, q) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(hits) != 1 || hits[0].Item.Slug != "xiao-li" { - t.Errorf("hits: %+v", hits) - } -} - -func TestRunKindRestriction(t *testing.T) { - wd := seed(t) - q, _ := Parse(`kind:reference`) - hits, err := Run(wd, q) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(hits) != 1 || hits[0].Item.Kind != registry.KindReference { - t.Errorf("hits: %+v", hits) - } -} - -func TestRunNegativeSubstringExcludesMatches(t *testing.T) { - wd := seed(t) - q, _ := Parse(`kind:character -photographer`) - hits, err := Run(wd, q) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(hits) != 1 || hits[0].Item.Slug != "lao-wang" { - t.Errorf("hits: %+v", hits) - } -} - -func TestRunCombinationOfTagAndSubstring(t *testing.T) { - wd := seed(t) - q, _ := Parse(`night tag:scene`) - hits, err := Run(wd, q) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(hits) != 1 || hits[0].Item.Slug != "kitchen-night" { - t.Errorf("hits: %+v", hits) - } -} - -func TestRunCaseInsensitiveSubstring(t *testing.T) { - wd := seed(t) - q, _ := Parse(`VENDOR`) - hits, err := Run(wd, q) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(hits) != 1 { - t.Errorf("expected 1 hit, got %d", len(hits)) - } -} - -func TestRunOrdersByScoreThenSlug(t *testing.T) { - wd := mustInit(t) - mustAdd(t, wd, registry.AddOptions{ - Kind: registry.KindCharacter, Slug: "a1", Name: "A", - Description: "vendor", Tags: []string{"vendor"}, - }) - mustAdd(t, wd, registry.AddOptions{ - Kind: registry.KindCharacter, Slug: "b1", Name: "B vendor vendor", - Description: "vendor vendor", Tags: []string{"vendor"}, - }) - q, _ := Parse(`vendor`) - hits, err := Run(wd, q) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(hits) != 2 { - t.Fatalf("expected 2 hits, got %d", len(hits)) - } - // b1 has more substring hits. - if hits[0].Item.Slug != "b1" { - t.Errorf("expected b1 first, got %s", hits[0].Item.Slug) - } -} diff --git a/internal/session/hooks.go b/internal/session/hooks.go deleted file mode 100644 index cc370cc..0000000 --- a/internal/session/hooks.go +++ /dev/null @@ -1,92 +0,0 @@ -package session - -import ( - "context" - - "github.com/eight-acres-lab/openmelon/internal/hooks" -) - -type sessionHookRecorder struct { - hooks.NoopManager - s *Session -} - -func (r sessionHookRecorder) BeforeModelRequest(_ context.Context, e hooks.ModelRequestEvent) hooks.HookResult { - _ = r.s.AppendEvent("model_request", EventRecord{ - Step: e.Step, - Status: "before", - Detail: map[string]any{ - "messages": len(e.Messages), - "tools": len(e.Tools), - }, - }) - return hooks.HookResult{} -} - -func (r sessionHookRecorder) AfterModelResponse(_ context.Context, e hooks.ModelResponseEvent) hooks.HookResult { - _ = r.s.AppendEvent("model_response", EventRecord{ - Step: e.Step, - Status: string(e.Response.FinishReason), - Detail: map[string]any{ - "tool_calls": len(e.Response.Message.ToolCalls), - "content_chars": len(e.Response.Message.Content), - "prompt_tokens": e.Response.Usage.PromptTokens, - "completion_tokens": e.Response.Usage.CompletionTokens, - }, - }) - return hooks.HookResult{} -} - -func (r sessionHookRecorder) BeforeToolCall(_ context.Context, e hooks.ToolCallEvent) hooks.HookResult { - _ = r.s.AppendEvent("tool_call", EventRecord{ - Step: e.Step, - Tool: e.Call.Name, - Status: "before", - Detail: map[string]any{ - "tool_call_id": e.Call.ID, - }, - }) - return hooks.HookResult{} -} - -func (r sessionHookRecorder) AfterToolCall(_ context.Context, e hooks.ToolResultEvent) hooks.HookResult { - status := "ok" - if e.Err != nil { - status = "error" - } - _ = r.s.AppendEvent("tool_result", EventRecord{ - Step: e.Step, - Tool: e.Call.Name, - Status: status, - Detail: map[string]any{ - "tool_call_id": e.Call.ID, - "content_chars": len(e.Content), - }, - }) - return hooks.HookResult{} -} - -func (r sessionHookRecorder) BeforeContinuityWrite(_ context.Context, e hooks.ContinuityWriteEvent) hooks.HookResult { - _ = r.s.AppendEvent("continuity_write", EventRecord{ - Tool: e.Tool, - SpaceID: e.SpaceID, - Status: "before", - Detail: map[string]any{ - "payload_chars": len(e.Payload), - }, - }) - return hooks.HookResult{} -} - -func (r sessionHookRecorder) AfterContinuityWrite(_ context.Context, e hooks.ContinuityWriteEvent) hooks.HookResult { - status := "ok" - if e.Err != nil { - status = "error" - } - _ = r.s.AppendEvent("continuity_write", EventRecord{ - Tool: e.Tool, - SpaceID: e.SpaceID, - Status: status, - }) - return hooks.HookResult{} -} diff --git a/internal/session/session.go b/internal/session/session.go deleted file mode 100644 index 23ca3e7..0000000 --- a/internal/session/session.go +++ /dev/null @@ -1,471 +0,0 @@ -// Package session writes per-run conversation history under -// /.openmelon/sessions//. -// -// One session captures one end-to-end agent run: the system prompt, the -// user input, every model reply, every tool call + tool result, and any -// generated images saved into the session directory. The directory is -// the unit of resumability: copy it to share, point a future -// `openmelon --session ` at it to re-enter mid-conversation. -// -// Layout: -// -// sessions// -// meta.json — session id, started_at, project id, intent -// messages.jsonl — one JSON message per line (llm.Message shape) -// summary.json — set when the runtime finishes; carries the -// finish-tool summary + final artifact paths -// *.png|jpg — generated images written by generate_image -package session - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -// Session is a writable session directory. -type Session struct { - ID string - Dir string - StartedAt time.Time - Workdir string - ProjectID string - Intent string - Provider string - Model string - ResumedFrom string - msgFile *os.File -} - -const SchemaVersion = 2 - -type Meta struct { - Version int `json:"version"` - ID string `json:"id"` - ProjectID string `json:"project_id"` - Intent string `json:"intent"` - StartedAt time.Time `json:"started_at"` - WorkspaceRoot string `json:"workspace_root,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - ResumedFrom string `json:"resumed_from,omitempty"` -} - -type PromptRecord struct { - At time.Time `json:"at"` - Kind string `json:"kind"` - Content string `json:"content"` -} - -type CompactionRecord struct { - At time.Time `json:"at"` - MessageStart int `json:"message_start"` - MessageEnd int `json:"message_end"` - Summary string `json:"summary"` -} - -type EventRecord struct { - At time.Time `json:"at"` - Type string `json:"type"` - Step int `json:"step,omitempty"` - Tool string `json:"tool,omitempty"` - SpaceID string `json:"space_id,omitempty"` - Status string `json:"status,omitempty"` - Detail map[string]any `json:"detail,omitempty"` -} - -// New creates a fresh session under /.openmelon/sessions//. -// -// The id is "-<8 hex chars>" so directory listings sort -// chronologically and collisions across parallel runs are vanishingly -// unlikely. resumedFrom, if non-empty, is recorded in meta.json so the -// new session keeps a breadcrumb back to the conversation it -// continues. -func New(workdir, projectID, intent string) (*Session, error) { - return NewResume(workdir, projectID, intent, "") -} - -// NewResume is like New but tags the session as a continuation of -// resumedFrom in meta.json. -func NewResume(workdir, projectID, intent, resumedFrom string) (*Session, error) { - now := time.Now().UTC() - id := now.Format("20060102-150405") + "-" + randHex(4) - dir := filepath.Join(projectx.StateDir(workdir), "sessions", id) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("session: mkdir %s: %w", dir, err) - } - s := &Session{ - ID: id, - Dir: dir, - StartedAt: now, - Workdir: workdir, - ProjectID: projectID, - Intent: intent, - ResumedFrom: resumedFrom, - } - meta := map[string]any{ - "version": SchemaVersion, - "id": id, - "project_id": projectID, - "intent": intent, - "started_at": now.Format(time.RFC3339), - "workspace_root": workdir, - } - if resumedFrom != "" { - meta["resumed_from"] = resumedFrom - } - body, _ := json.MarshalIndent(meta, "", " ") - if err := os.WriteFile(filepath.Join(dir, "meta.json"), append(body, '\n'), 0o644); err != nil { - return nil, err - } - f, err := os.OpenFile(filepath.Join(dir, "messages.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return nil, err - } - s.msgFile = f - return s, nil -} - -func (s *Session) SetRuntimeInfo(provider, model string) error { - s.Provider = strings.TrimSpace(provider) - s.Model = strings.TrimSpace(model) - return s.rewriteMeta() -} - -func (s *Session) AppendPrompt(kind, content string) error { - content = strings.TrimSpace(content) - if content == "" { - return nil - } - kind = strings.TrimSpace(kind) - if kind == "" { - kind = "user" - } - return appendJSONL(filepath.Join(s.Dir, "prompt_history.jsonl"), PromptRecord{ - At: time.Now().UTC(), - Kind: kind, - Content: content, - }) -} - -func (s *Session) AppendCompaction(start, end int, summary string) error { - summary = strings.TrimSpace(summary) - if summary == "" { - return nil - } - return appendJSONL(filepath.Join(s.Dir, "compactions.jsonl"), CompactionRecord{ - At: time.Now().UTC(), - MessageStart: start, - MessageEnd: end, - Summary: summary, - }) -} - -func (s *Session) AppendEvent(eventType string, rec EventRecord) error { - eventType = strings.TrimSpace(eventType) - if eventType == "" { - return nil - } - rec.At = time.Now().UTC() - rec.Type = eventType - return appendJSONL(filepath.Join(s.Dir, "events.jsonl"), rec) -} - -func (s *Session) HookRecorder() hooks.Manager { - if s == nil { - return nil - } - return sessionHookRecorder{s: s} -} - -// AppendMessages persists each message as one JSONL line. Idempotent -// with respect to ordering: the runtime calls AppendMessages with the -// full delta since the last call, so we don't have to track cursors. -func (s *Session) AppendMessages(msgs []llm.Message) error { - for _, m := range msgs { - b, err := json.Marshal(m) - if err != nil { - return err - } - if _, err := s.msgFile.Write(append(b, '\n')); err != nil { - return err - } - } - return nil -} - -// WriteSummary writes the final summary.json. Best-effort — failure -// here is logged but doesn't fail the run. -func (s *Session) WriteSummary(summary string, artifacts []string, finished bool) error { - body, _ := json.MarshalIndent(map[string]any{ - "id": s.ID, - "finished": finished, - "summary": summary, - "artifacts": artifacts, - "finished_at": time.Now().UTC().Format(time.RFC3339), - }, "", " ") - return os.WriteFile(filepath.Join(s.Dir, "summary.json"), append(body, '\n'), 0o644) -} - -// Close flushes + closes the messages file. Safe to call once. -func (s *Session) Close() error { - if s.msgFile == nil { - return nil - } - err := s.msgFile.Close() - s.msgFile = nil - return err -} - -func (s *Session) rewriteMeta() error { - body, _ := json.MarshalIndent(map[string]any{ - "version": SchemaVersion, - "id": s.ID, - "project_id": s.ProjectID, - "intent": s.Intent, - "started_at": s.StartedAt.Format(time.RFC3339), - "workspace_root": s.Workdir, - "provider": s.Provider, - "model": s.Model, - }, "", " ") - var meta map[string]any - if err := json.Unmarshal(body, &meta); err == nil && s.ResumedFrom != "" { - meta["resumed_from"] = s.ResumedFrom - body, _ = json.MarshalIndent(meta, "", " ") - } - return os.WriteFile(filepath.Join(s.Dir, "meta.json"), append(body, '\n'), 0o644) -} - -func LoadMeta(workdir, sessionID string) (Meta, error) { - path := filepath.Join(projectx.StateDir(workdir), "sessions", sessionID, "meta.json") - b, err := os.ReadFile(path) - if err != nil { - return Meta{}, fmt.Errorf("session: read %s: %w", path, err) - } - var raw struct { - Version int `json:"version"` - ID string `json:"id"` - ProjectID string `json:"project_id"` - Intent string `json:"intent"` - StartedAt string `json:"started_at"` - WorkspaceRoot string `json:"workspace_root"` - Provider string `json:"provider"` - Model string `json:"model"` - ResumedFrom string `json:"resumed_from"` - } - if err := json.Unmarshal(b, &raw); err != nil { - return Meta{}, fmt.Errorf("session: parse %s: %w", path, err) - } - startedAt, _ := time.Parse(time.RFC3339, raw.StartedAt) - return Meta{ - Version: raw.Version, - ID: raw.ID, - ProjectID: raw.ProjectID, - Intent: raw.Intent, - StartedAt: startedAt, - WorkspaceRoot: raw.WorkspaceRoot, - Provider: raw.Provider, - Model: raw.Model, - ResumedFrom: raw.ResumedFrom, - }, nil -} - -func ValidateWorkspace(workdir, sessionID string) error { - meta, err := LoadMeta(workdir, sessionID) - if err != nil { - return err - } - if strings.TrimSpace(meta.WorkspaceRoot) == "" { - return nil - } - want, err := filepath.Abs(workdir) - if err != nil { - return err - } - got, err := filepath.Abs(meta.WorkspaceRoot) - if err != nil { - return err - } - if got != want { - return fmt.Errorf("session: workspace mismatch for %s: session belongs to %s, current project is %s", sessionID, got, want) - } - return nil -} - -// LoadHistory reads messages.jsonl from the named session and returns -// the parsed message list. Used by `openmelon resume` to seed a new -// TUI with the prior conversation. -func LoadHistory(workdir, sessionID string) ([]llm.Message, error) { - path := filepath.Join(projectx.StateDir(workdir), "sessions", sessionID, "messages.jsonl") - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("session: open %s: %w", path, err) - } - defer f.Close() - var out []llm.Message - dec := json.NewDecoder(f) - for { - var m llm.Message - if err := dec.Decode(&m); err != nil { - if errors.Is(err, io.EOF) { - break - } - return nil, fmt.Errorf("session: parse %s: %w", path, err) - } - out = append(out, m) - } - return out, nil -} - -func LoadEvents(workdir, sessionID string, limit int) ([]EventRecord, error) { - path := filepath.Join(projectx.StateDir(workdir), "sessions", sessionID, "events.jsonl") - events, err := readSessionJSONL[EventRecord](path, limit) - if err != nil { - return nil, fmt.Errorf("session: parse %s: %w", path, err) - } - return events, nil -} - -// Summary is the lightweight metadata record used by Recent. The -// FirstUserMessage field is populated from messages.jsonl when -// present so the picker can show a preview alongside the id. -type Summary struct { - ID string - StartedAt time.Time - Intent string - ResumedFrom string - FirstUserMessage string - TurnCount int -} - -// Recent returns the most recent N sessions for the project at workdir, -// sorted newest-first. Sessions whose meta.json is unreadable are -// skipped silently. -func Recent(workdir string, limit int) ([]Summary, error) { - root := filepath.Join(projectx.StateDir(workdir), "sessions") - entries, err := os.ReadDir(root) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var out []Summary - for _, e := range entries { - if !e.IsDir() { - continue - } - s, ok := loadSummary(workdir, e.Name()) - if !ok { - continue - } - out = append(out, s) - } - sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) }) - if limit > 0 && len(out) > limit { - out = out[:limit] - } - return out, nil -} - -func loadSummary(workdir, id string) (Summary, bool) { - dir := filepath.Join(projectx.StateDir(workdir), "sessions", id) - body, err := os.ReadFile(filepath.Join(dir, "meta.json")) - if err != nil { - return Summary{}, false - } - var meta struct { - ID string `json:"id"` - Intent string `json:"intent"` - StartedAt string `json:"started_at"` - ResumedFrom string `json:"resumed_from"` - } - if err := json.Unmarshal(body, &meta); err != nil { - return Summary{}, false - } - startedAt, _ := time.Parse(time.RFC3339, meta.StartedAt) - s := Summary{ - ID: meta.ID, - Intent: meta.Intent, - StartedAt: startedAt, - ResumedFrom: meta.ResumedFrom, - } - // Pull the first user message + turn count for the picker. - if msgs, err := LoadHistory(workdir, id); err == nil { - s.TurnCount = len(msgs) - for _, m := range msgs { - if m.Role == llm.RoleUser && strings.TrimSpace(m.Content) != "" { - s.FirstUserMessage = m.Content - break - } - } - } - return s, true -} - -func randHex(n int) string { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - // Fall back to a timestamp-derived suffix; collisions still - // unlikely because the parent dir name already includes - // per-second granularity. - now := time.Now().UnixNano() - for i := range b { - b[i] = byte(now >> (i * 8)) - } - } - return hex.EncodeToString(b) -} - -func appendJSONL(path string, v any) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - b, err := json.Marshal(v) - if err != nil { - return err - } - _, err = f.Write(append(b, '\n')) - return err -} - -func readSessionJSONL[T any](path string, limit int) ([]T, error) { - f, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer f.Close() - var out []T - dec := json.NewDecoder(f) - for { - var v T - if err := dec.Decode(&v); err != nil { - if errors.Is(err, io.EOF) { - break - } - return nil, err - } - out = append(out, v) - } - if limit > 0 && len(out) > limit { - out = out[len(out)-limit:] - } - return out, nil -} diff --git a/internal/session/session_test.go b/internal/session/session_test.go deleted file mode 100644 index 2420195..0000000 --- a/internal/session/session_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package session - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -func TestNewCreatesDirAndMeta(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - s, err := New(wd, "ai-talks", "Lao Wang sells noodles") - if err != nil { - t.Fatalf("New: %v", err) - } - defer s.Close() - - if _, err := os.Stat(s.Dir); err != nil { - t.Errorf("session dir missing: %v", err) - } - meta, err := os.ReadFile(filepath.Join(s.Dir, "meta.json")) - if err != nil { - t.Fatalf("read meta: %v", err) - } - var m map[string]any - if err := json.Unmarshal(meta, &m); err != nil { - t.Fatalf("parse meta: %v", err) - } - if m["intent"] != "Lao Wang sells noodles" { - t.Errorf("intent: %v", m["intent"]) - } - if m["project_id"] != "ai-talks" { - t.Errorf("project_id: %v", m["project_id"]) - } - if m["version"].(float64) != SchemaVersion { - t.Errorf("version: %v", m["version"]) - } - if m["workspace_root"] != wd { - t.Errorf("workspace_root: %v", m["workspace_root"]) - } -} - -func TestRuntimeInfoAndPromptHistory(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - s, err := NewResume(wd, "ai-talks", "x", "old-session") - if err != nil { - t.Fatalf("NewResume: %v", err) - } - defer s.Close() - if err := s.SetRuntimeInfo("openrouter", "openai/gpt-5"); err != nil { - t.Fatalf("SetRuntimeInfo: %v", err) - } - if err := s.AppendPrompt("pending", "make it shorter"); err != nil { - t.Fatalf("AppendPrompt: %v", err) - } - meta, err := LoadMeta(wd, s.ID) - if err != nil { - t.Fatalf("LoadMeta: %v", err) - } - if meta.Provider != "openrouter" || meta.Model != "openai/gpt-5" || meta.ResumedFrom != "old-session" { - t.Fatalf("meta missing runtime info: %+v", meta) - } - b, err := os.ReadFile(filepath.Join(s.Dir, "prompt_history.jsonl")) - if err != nil { - t.Fatalf("read prompt history: %v", err) - } - if !strings.Contains(string(b), `"kind":"pending"`) || !strings.Contains(string(b), "make it shorter") { - t.Fatalf("prompt history missing record: %s", string(b)) - } -} - -func TestAppendEventAndValidateWorkspace(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - s, err := New(wd, "ai-talks", "x") - if err != nil { - t.Fatalf("New: %v", err) - } - defer s.Close() - if err := s.AppendEvent("tool_call", EventRecord{Tool: "search", Status: "ok"}); err != nil { - t.Fatalf("AppendEvent: %v", err) - } - if err := ValidateWorkspace(wd, s.ID); err != nil { - t.Fatalf("ValidateWorkspace: %v", err) - } - b, err := os.ReadFile(filepath.Join(s.Dir, "events.jsonl")) - if err != nil { - t.Fatalf("read events: %v", err) - } - if !strings.Contains(string(b), `"type":"tool_call"`) || !strings.Contains(string(b), `"tool":"search"`) { - t.Fatalf("events missing record: %s", string(b)) - } - events, err := LoadEvents(wd, s.ID, 10) - if err != nil { - t.Fatalf("LoadEvents: %v", err) - } - if len(events) != 1 || events[0].Tool != "search" { - t.Fatalf("loaded events mismatch: %+v", events) - } -} - -func TestAppendMessagesWritesJSONL(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - s, err := New(wd, "ai-talks", "x") - if err != nil { - t.Fatalf("New: %v", err) - } - defer s.Close() - - msgs := []llm.Message{ - {Role: llm.RoleSystem, Content: "be terse"}, - {Role: llm.RoleUser, Content: "hi"}, - {Role: llm.RoleAssistant, Content: "hello"}, - } - if err := s.AppendMessages(msgs); err != nil { - t.Fatalf("AppendMessages: %v", err) - } - if err := s.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - - f, err := os.Open(filepath.Join(s.Dir, "messages.jsonl")) - if err != nil { - t.Fatalf("open messages: %v", err) - } - defer f.Close() - sc := bufio.NewScanner(f) - var lines []string - for sc.Scan() { - lines = append(lines, sc.Text()) - } - if len(lines) != 3 { - t.Fatalf("expected 3 lines, got %d", len(lines)) - } - if !strings.Contains(lines[0], `"role":"system"`) { - t.Errorf("first line missing system role: %s", lines[0]) - } -} - -func TestWriteSummary(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - s, err := New(wd, "ai-talks", "x") - if err != nil { - t.Fatalf("New: %v", err) - } - if err := s.WriteSummary("all done", []string{"/tmp/a.png"}, true); err != nil { - t.Fatalf("WriteSummary: %v", err) - } - b, err := os.ReadFile(filepath.Join(s.Dir, "summary.json")) - if err != nil { - t.Fatalf("read summary: %v", err) - } - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - t.Fatalf("parse summary: %v", err) - } - if m["summary"] != "all done" || m["finished"] != true { - t.Errorf("summary mismatch: %+v", m) - } -} diff --git a/internal/skillplus/compiled_skill.go b/internal/skillplus/compiled_skill.go deleted file mode 100644 index e6f394e..0000000 --- a/internal/skillplus/compiled_skill.go +++ /dev/null @@ -1,12 +0,0 @@ -package skillplus - -// CompiledSkill is the OpenMelon-facing result of compiling a Skill-Plus package. -type CompiledSkill struct { - PackageID string - PackageVersion string - Target string - ModelProfile string - RuntimeVars map[string]string - Prompt string - Evaluation []string -} diff --git a/internal/skillplus/compiler.go b/internal/skillplus/compiler.go deleted file mode 100644 index f81590a..0000000 --- a/internal/skillplus/compiler.go +++ /dev/null @@ -1,177 +0,0 @@ -package skillplus - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// CompileRequest holds the parameters for a Skill-Plus compilation. -type CompileRequest struct { - PackagePath string - Target string - ModelProfile string - Locale string - Vars map[string]string -} - -// Compiler invokes the Skill-Plus reference compiler. -// -// Resolution order for how to invoke the compiler: -// -// 1. If CompilerPath is empty AND the `skillplus` console script is on -// PATH (because the user has run `pip install skillplus`), invoke it -// directly: `skillplus --target ...`. No PYTHONPATH gymnastics. -// -// 2. If CompilerPath is set, invoke `python -m skillplus` with -// PYTHONPATH=. Useful for local development against an -// editable checkout of skillplus (point CompilerPath at -// `/src`). -// -// 3. If both fail, return a clear error explaining the install / -// PYTHONPATH options. -type Compiler struct { - // CompilerPath is added to PYTHONPATH when invoking via `python -m`. - // Leave empty to prefer the `skillplus` console script on PATH. - CompilerPath string - // PythonCmd overrides the Python executable used in mode (2). Default - // "python3". Useful in tests. - PythonCmd string - // SkillplusBinary overrides the console-script command used in mode - // (1). Default "skillplus". Useful in tests. - SkillplusBinary string -} - -// rawOutput is the slim view the workflow engine has historically used. -// The agent loop wants the full compiler output instead — see CompileRaw. -type rawOutput struct { - Target string `json:"target"` - Package struct { - ID string `json:"id"` - Version string `json:"version"` - } `json:"package"` - CompiledPrompt string `json:"compiled_prompt"` - RuntimeVars map[string]string `json:"runtime_vars"` - ModelProfile string `json:"model_profile"` - Evaluation struct { - Checklist []string `json:"checklist"` - } `json:"evaluation"` -} - -// Compile invokes the Skill-Plus compiler subprocess and returns a slim -// CompiledSkill (the workflow-engine view). -func (c *Compiler) Compile(ctx context.Context, req *CompileRequest) (*CompiledSkill, error) { - stdout, err := c.runCompiler(ctx, req) - if err != nil { - return nil, err - } - - var raw rawOutput - if err := json.Unmarshal(stdout, &raw); err != nil { - return nil, fmt.Errorf("skillplus: failed to parse compiler output: %w", err) - } - - return &CompiledSkill{ - PackageID: raw.Package.ID, - PackageVersion: raw.Package.Version, - Target: raw.Target, - ModelProfile: raw.ModelProfile, - RuntimeVars: raw.RuntimeVars, - Prompt: raw.CompiledPrompt, - Evaluation: raw.Evaluation.Checklist, - }, nil -} - -// CompileRaw returns the full compiler JSON output unparsed. -// -// The agent loop uses this to feed the LLM the entire compiled object — -// output_schema, stage_contract, evaluation, runtime_vars — without the -// workflow engine's slim CompiledSkill view dropping fields. -func (c *Compiler) CompileRaw(ctx context.Context, req *CompileRequest) (json.RawMessage, error) { - stdout, err := c.runCompiler(ctx, req) - if err != nil { - return nil, err - } - // Validate it parses, but return the bytes verbatim so the caller can - // re-marshal or pluck fields without re-running the compiler. - var probe map[string]any - if err := json.Unmarshal(stdout, &probe); err != nil { - return nil, fmt.Errorf("skillplus: compiler output is not valid JSON: %w", err) - } - return json.RawMessage(stdout), nil -} - -// runCompiler picks an invocation mode and returns raw stdout. -func (c *Compiler) runCompiler(ctx context.Context, req *CompileRequest) ([]byte, error) { - cmd, err := c.buildCommand(ctx, req) - if err != nil { - return nil, err - } - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - detail := strings.TrimSpace(stderr.String()) - if detail == "" { - detail = err.Error() - } - return nil, fmt.Errorf("skillplus compile failed for %q: %s", req.PackagePath, detail) - } - return stdout.Bytes(), nil -} - -func (c *Compiler) buildCommand(ctx context.Context, req *CompileRequest) (*exec.Cmd, error) { - args := []string{req.PackagePath, - "--target", req.Target, - "--model-profile", req.ModelProfile, - } - if req.Locale != "" { - args = append(args, "--locale", req.Locale) - } - for k, v := range req.Vars { - args = append(args, "--var", k+"="+v) - } - - // Mode 1: console script (preferred whenever available on PATH, - // regardless of whether CompilerPath is set). - binary := c.skillplusBinary() - if _, err := exec.LookPath(binary); err == nil { - return exec.CommandContext(ctx, binary, args...), nil - } - - // Mode 2: `python -m skillplus` with optional PYTHONPATH=. - pythonCmd := c.pythonCmd() - if _, err := exec.LookPath(pythonCmd); err != nil { - return nil, fmt.Errorf( - "skillplus: neither %q nor %q is on PATH — install with `pip install skillplus`, "+ - "or pass --compiler for editable use: %w", - binary, pythonCmd, err, - ) - } - cmd := exec.CommandContext(ctx, pythonCmd, append([]string{"-m", "skillplus"}, args...)...) - if c.CompilerPath != "" { - cmd.Env = append(os.Environ(), "PYTHONPATH="+filepath.Clean(c.CompilerPath)) - } else { - cmd.Env = os.Environ() - } - return cmd, nil -} - -func (c *Compiler) pythonCmd() string { - if c.PythonCmd != "" { - return c.PythonCmd - } - return "python3" -} - -func (c *Compiler) skillplusBinary() string { - if c.SkillplusBinary != "" { - return c.SkillplusBinary - } - return "skillplus" -} diff --git a/internal/skillplus/compiler_test.go b/internal/skillplus/compiler_test.go deleted file mode 100644 index 577507b..0000000 --- a/internal/skillplus/compiler_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package skillplus - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// validCompiledSkillJSON is a minimal valid response from the Python compiler. -const validCompiledSkillJSON = `{ - "target": "openmelon", - "package": {"id": "food-street-realism", "version": "1.0.0"}, - "compiled_prompt": "test compiled prompt", - "runtime_vars": {"realism_level": "high"}, - "model_profile": "gpt-image-family", - "evaluation": {"checklist": ["check sharpness", "check realism"]}, - "output_schema": {"type": "object"}, - "stage_contract": {"stage": "visual_prompt_concretization"} -}` - -func TestCompiler_pythonNotFound(t *testing.T) { - c := &Compiler{ - CompilerPath: "/fake", - PythonCmd: "/nonexistent/python99", - // Force mode-2 path: pretend the console script is also unfindable. - SkillplusBinary: "/nonexistent/skillplus99", - } - req := &CompileRequest{ - PackagePath: "/some/package", - Target: "openmelon", - ModelProfile: "gpt-image-family", - } - _, err := c.Compile(context.Background(), req) - if err == nil { - t.Fatal("expected error for missing python, got nil") - } - if !strings.Contains(err.Error(), "is on PATH") || !strings.Contains(err.Error(), "pip install skillplus") { - t.Errorf("expected install hint in error, got: %v", err) - } -} - -func TestCompiler_successPythonMode(t *testing.T) { - tmpDir := t.TempDir() - fakePython := filepath.Join(tmpDir, "fake_python3") - script := "#!/bin/sh\ncat << 'ENDJSON'\n" + validCompiledSkillJSON + "\nENDJSON\n" - if err := os.WriteFile(fakePython, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - - c := &Compiler{ - CompilerPath: tmpDir, - PythonCmd: fakePython, - SkillplusBinary: "/nonexistent/skillplus", // force python mode - } - req := &CompileRequest{ - PackagePath: "/some/food.skillplus", - Target: "openmelon", - ModelProfile: "gpt-image-family", - Locale: "zh-CN", - Vars: map[string]string{"realism_level": "high"}, - } - - got, err := c.Compile(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.PackageID != "food-street-realism" { - t.Errorf("PackageID = %q, want %q", got.PackageID, "food-street-realism") - } - if got.Prompt != "test compiled prompt" { - t.Errorf("Prompt = %q, want %q", got.Prompt, "test compiled prompt") - } - if len(got.Evaluation) != 2 { - t.Errorf("Evaluation len = %d, want 2", len(got.Evaluation)) - } -} - -func TestCompiler_successConsoleScriptMode(t *testing.T) { - tmpDir := t.TempDir() - fakeBin := filepath.Join(tmpDir, "skillplus") - script := "#!/bin/sh\ncat << 'ENDJSON'\n" + validCompiledSkillJSON + "\nENDJSON\n" - if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - - c := &Compiler{ - // CompilerPath empty → prefer console script. - SkillplusBinary: fakeBin, - } - req := &CompileRequest{ - PackagePath: "/some/food.skillplus", - Target: "openmelon", - ModelProfile: "gpt-image-family", - } - got, err := c.Compile(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.PackageID != "food-street-realism" { - t.Errorf("PackageID = %q, want %q", got.PackageID, "food-street-realism") - } -} - -func TestCompileRaw_returnsFullJSON(t *testing.T) { - tmpDir := t.TempDir() - fakeBin := filepath.Join(tmpDir, "skillplus") - script := "#!/bin/sh\ncat << 'ENDJSON'\n" + validCompiledSkillJSON + "\nENDJSON\n" - if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - - c := &Compiler{SkillplusBinary: fakeBin} - raw, err := c.CompileRaw(context.Background(), &CompileRequest{ - PackagePath: "/x", Target: "openmelon", ModelProfile: "gpt-image-family", - }) - if err != nil { - t.Fatalf("CompileRaw: %v", err) - } - - var parsed map[string]any - if err := json.Unmarshal(raw, &parsed); err != nil { - t.Fatalf("returned bytes not valid JSON: %v", err) - } - if _, ok := parsed["output_schema"]; !ok { - t.Errorf("expected output_schema in raw output (slim Compile would drop it)") - } - if _, ok := parsed["stage_contract"]; !ok { - t.Errorf("expected stage_contract in raw output (slim Compile would drop it)") - } -} diff --git a/internal/skillplus/list.go b/internal/skillplus/list.go deleted file mode 100644 index 92ef7d6..0000000 --- a/internal/skillplus/list.go +++ /dev/null @@ -1,45 +0,0 @@ -package skillplus - -// list.go — wrapper around `skillplus list --json` for the TUI's -// /skill picker. Shelling out (vs. reading catalog.json + ~/.skillplus/ -// directly) keeps openmelon decoupled from skillplus's on-disk layout -// — the CLI is the contract. - -import ( - "context" - "encoding/json" - "fmt" - "os/exec" -) - -// SkillInfo is one entry from `skillplus list --json`. Same shape the -// skillplus catalog uses; openmelon only cares about the few fields -// below for the picker UI. -type SkillInfo struct { - ID string `json:"id"` - Version string `json:"version"` - Name string `json:"name"` - Description string `json:"description"` - Tags []string `json:"tags"` - Path string `json:"path"` - Source string `json:"source"` // "local" or "bundled" -} - -// ListSkills runs `skillplus list --json` and parses the result. -// Returns an empty slice (not an error) when the skillplus CLI isn't -// installed — the picker just shows "no skills found" in that case. -func ListSkills(ctx context.Context) ([]SkillInfo, error) { - if _, err := exec.LookPath("skillplus"); err != nil { - return nil, nil - } - cmd := exec.CommandContext(ctx, "skillplus", "list", "--json") - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("skillplus list: %w", err) - } - var skills []SkillInfo - if err := json.Unmarshal(out, &skills); err != nil { - return nil, fmt.Errorf("skillplus list: parse: %w", err) - } - return skills, nil -} diff --git a/internal/tools/bash.go b/internal/tools/bash.go deleted file mode 100644 index e6023b5..0000000 --- a/internal/tools/bash.go +++ /dev/null @@ -1,166 +0,0 @@ -package tools - -// bash.go — the bash tool. Lets the agent run shell commands inside -// the project workdir, gated by a three-tier approval system: -// -// 1. Per-session allowlist — binaries the user has explicitly -// "always-approved" this session (e.g. "file", "open", "identify") -// run without judge or modal. -// 2. Judge LLM (optional) — classifies into AUTO / ASK / BLOCK. -// Mode controls who sees the result: -// strict AUTO + ASK both prompt; only BLOCK auto-refuses. -// auto AUTO runs silently; ASK prompts; BLOCK refuses. -// trusted everything runs (judge bypassed entirely). -// 3. User modal — final fallback. Three options: -// Yes / Yes always for / No. -// -// trusted mode is "Claude Code's --dangerously-skip-permissions": no -// approval, model is on the honor system. The user toggles it via the -// /settings panel; we default to strict. - -import ( - "context" - "encoding/json" - "fmt" - "os/exec" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/policy" -) - -func bashTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "bash", - Description: "Run a shell command inside the project workdir and return its combined stdout/stderr. " + - "Use sparingly — for inspecting files (file, ls, du), checking output (open, identify), " + - "or quick text edits. Do not use bash to discover fonts, render SVG/HTML, compose images, " + - "or substitute for image generation; typography/style should be handled as continuity context " + - "and image prompt constraints. Each call is gated by the project's bash permission policy.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "command": {"type": "string", "description": "The shell command to run. Will be passed to /bin/sh -c."}, - "description": {"type": "string", "description": "One-line plain-English explanation of why you're running this. Shown to the user in the approval modal."}, - "timeout_seconds": {"type": "number", "description": "Kill the command after this many seconds. Default 30, max 300."} - }, - "required": ["command", "description"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct { - Command string `json:"command"` - Description string `json:"description"` - TimeoutSeconds float64 `json:"timeout_seconds"` - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - if args.Command == "" { - return map[string]any{"error": "command is required"}, nil - } - binary := firstBinary(args.Command) - policyRes := env.policy().Check(ctx, policy.Request{ - Action: "bash.execute", - Tool: "bash", - Workdir: env.Workdir, - Command: args.Command, - Description: args.Description, - Binary: binary, - }) - switch policy.NormalizeDecision(policyRes.Decision) { - case policy.Deny: - return map[string]any{ - "error": policy.ReasonOrDefault(policyRes.Reason, "blocked by policy"), - }, nil - case policy.Allow: - return runBash(ctx, env.Workdir, args.Command, args.TimeoutSeconds, policy.ReasonOrDefault(policyRes.Reason, "policy")) - } - - if env.Approve == nil { - return map[string]any{ - "error": "bash is unavailable: no approval gate is wired (running headless?)", - }, nil - } - decision := env.Approve(ApprovalRequest{ - Tool: "bash", - Command: args.Command, - Description: args.Description, - Binary: binary, - }) - if !decision.Approved { - return map[string]any{"error": "user denied execution"}, nil - } - if decision.Always && env.AllowBash != nil { - env.AllowBash(binary) - } - return runBash(ctx, env.Workdir, args.Command, args.TimeoutSeconds, "user-approved") - }, - } -} - -// runBash executes the command and returns the structured result the -// model sees as the tool message. via labels how the call was approved -// for provenance ("user-approved", "allowlisted", "judge:auto", "trusted"). -func runBash(ctx context.Context, workdir, command string, timeoutSec float64, via string) (any, error) { - timeout := time.Duration(timeoutSec) * time.Second - if timeout <= 0 { - timeout = 30 * time.Second - } - if timeout > 5*time.Minute { - timeout = 5 * time.Minute - } - execCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - cmd := exec.CommandContext(execCtx, "/bin/sh", "-c", command) - cmd.Dir = workdir - out, err := cmd.CombinedOutput() - res := map[string]any{ - "stdout": string(out), - "exit_code": cmd.ProcessState.ExitCode(), - "approved_via": via, - } - if err != nil { - if execCtx.Err() == context.DeadlineExceeded { - res["error"] = fmt.Sprintf("timed out after %s", timeout) - } else if _, isExit := err.(*exec.ExitError); !isExit { - // exec.ExitError is normal — the model wants exit code - // + output regardless. Other errors are real failures. - res["error"] = err.Error() - } - } - return res, nil -} - -// firstBinary extracts the first executable name from a shell command. -// Strips leading env assignments, sudo, time, etc., and basenames the -// path so "/usr/bin/file" → "file". -// -// Best-effort: doesn't fully tokenize shell. Used purely for the -// allowlist key + the modal label, so a wrong answer just means the -// user might have to approve a similar command again — never a -// security violation. -func firstBinary(command string) string { - tokens := strings.Fields(command) - for _, t := range tokens { - if t == "" { - continue - } - // Skip env-var assignments like FOO=bar. - if strings.Contains(t, "=") && !strings.ContainsAny(t, "/\\") { - continue - } - // Skip common wrapper prefixes. - switch t { - case "sudo", "time", "exec", "nohup", "env": - continue - } - // Strip path. - if idx := strings.LastIndexAny(t, "/\\"); idx >= 0 { - t = t[idx+1:] - } - return t - } - return "" -} diff --git a/internal/tools/bash_judge.go b/internal/tools/bash_judge.go deleted file mode 100644 index 3622b99..0000000 --- a/internal/tools/bash_judge.go +++ /dev/null @@ -1,69 +0,0 @@ -package tools - -// bash_judge.go — wires a small classifier prompt against an -// llm.ToolCaller (or any client supporting Chat) so cmd_repl can plug -// the main agent LLM straight in as the bash safety judge. -// -// Returning BashAsk on any error keeps the system fail-safe: a network -// blip won't auto-approve a destructive command. - -import ( - "context" - "strings" - - "github.com/eight-acres-lab/openmelon/internal/llm" -) - -// JudgeBashWithLLM is a ready-made implementation of Env.JudgeBash that -// asks the given client to classify the command. cmd_repl wires this -// in with the main LLM as the judge — fast enough for normal use and -// avoids a second model dep. -// -// The classifier prompt is kept tight: 1-token output, no rationale, -// no markdown. We ignore everything past the first non-whitespace word. -func JudgeBashWithLLM(client llm.ToolCaller) func(context.Context, string, string) BashJudgement { - return func(ctx context.Context, command, description string) BashJudgement { - // Use the simpler Complete API if the client supports it, but - // ToolCaller.Chat works too — we just send no tools. - req := llm.ChatRequest{ - Messages: []llm.Message{ - {Role: llm.RoleSystem, Content: bashJudgeSystemPrompt}, - {Role: llm.RoleUser, Content: "Command: " + command + "\nDescription: " + description}, - }, - Temperature: 0.0, - MaxTokens: 8, - } - resp, err := client.Chat(ctx, req) - if err != nil { - return BashAsk - } - switch firstWord(resp.Message.Content) { - case "AUTO": - return BashAuto - case "BLOCK": - return BashBlock - default: - return BashAsk - } - } -} - -func firstWord(s string) string { - for _, f := range strings.Fields(strings.ToUpper(strings.TrimSpace(s))) { - // Strip surrounding punctuation the model might add. - f = strings.Trim(f, "`*_.,;:!?\"'") - if f != "" { - return f - } - } - return "" -} - -const bashJudgeSystemPrompt = `You are a safety classifier for an AI agent's bash tool. Given a shell command, output EXACTLY one of: - -AUTO Read-only inspection commands. Examples: ls, cat, file, head, tail, du, df, wc, grep, find (without -delete/-exec), stat, identify, exiftool, magick identify, pdfinfo, ffprobe, jq (without -i), xmllint, sha256sum, md5sum, base64, date, uname, hostname, uptime, ps, lsof, which, pwd, env, command -v, open (macOS), xdg-open. Anything that reads but doesn't write or call out. -ASK Writes inside the project workdir, normal-but-side-effecting commands during creative work. Examples: mkdir, touch, mv, cp, npm install, git commit, ImageMagick convert, ffmpeg encode, sed -i, python script.py, curl GET (read-only), pip install. -BLOCK Destructive or exfiltrating. Examples: rm -rf, dd, mkfs, sudo, chmod 777 /, anything piping to /etc, modifying ~/.ssh, curl/wget POST/PUT to a non-localhost URL, scp/rsync to a remote, eval/exec of remote content, nc -l (network listener), iptables, anything that reads secrets and sends them out. - -Respond with ONE WORD on a single line. No prose, no markdown, no explanation. If unsure, output ASK. -` diff --git a/internal/tools/builtin.go b/internal/tools/builtin.go deleted file mode 100644 index ccf9c34..0000000 --- a/internal/tools/builtin.go +++ /dev/null @@ -1,1423 +0,0 @@ -// builtin.go — the standard openmelon tool set. -// -// Tools come in two flavors: -// -// 1. Read-only project introspection: list_characters, get_character, -// list_references, get_reference, search. -// 2. Side-effecting actions: generate_image, save_artifact, -// compile_skill, finish. -// -// Side-effecting tools take a *Env that bundles the project workdir + -// any external clients (skillplus, image generator). Read-only tools -// only need the workdir. -// -// Registration is opt-in per tool — the runtime asks for "all read-only" -// or "everything" depending on whether keys are configured. - -package tools - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/eight-acres-lab/openmelon/internal/continuity" - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/imagegen" - "github.com/eight-acres-lab/openmelon/internal/policy" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/registry" - "github.com/eight-acres-lab/openmelon/internal/search" - "github.com/eight-acres-lab/openmelon/internal/skillplus" -) - -// Env bundles all the dependencies the side-effecting tools need. -// -// SessionDir is the per-run state directory under .openmelon/sessions//. -// OutputDir is the visible project directory where generated files are written. -// Hidden .openmelon paths are reserved for internal runtime state. -type Env struct { - Workdir string - Project *projectx.Project - SessionDir string - OutputDir string - - // Optional: nil means the matching tool is not registered. Runtime - // decides which to wire based on what's configured. - Compiler *skillplus.Compiler - ImageGen imagegen.Generator - - // Approve, when non-nil, is called by tools that need explicit - // user confirmation before running (notably bash). Returns the - // user's decision. Synchronous — the tool blocks until the user - // answers via whatever UI is wired (TUI modal, stdin prompt). - // nil means tools that need approval default-deny. - Approve func(req ApprovalRequest) ApprovalDecision - - // JudgeBash, when non-nil, is called BEFORE Approve. It classifies - // a command into AUTO / ASK / BLOCK; only ASK reaches the user. - // Typically backed by a small LLM call. nil means every command - // goes straight to Approve. - JudgeBash func(ctx context.Context, command, description string) BashJudgement - - // IsBashAllowed returns true when the binary (extracted from the - // command's first token) is on the per-session allowlist — - // previous "Yes, always" decisions populate it. nil → never - // auto-allow. - IsBashAllowed func(binary string) bool - - // AllowBash adds binary to the per-session allowlist. Called by - // the bash tool when the user picks "Yes, always" in the - // approval modal. - AllowBash func(binary string) - - // BashMode is the project's effective permission mode (strict / - // auto / trusted). The bash tool reads this each call. Empty - // string defaults to strict. - BashMode string - - // Hooks observes or gates lifecycle events. Continuity tools call - // Before/AfterContinuityWrite around durable creative-state writes. - Hooks hooks.Manager - - // Policy gates side effects. nil uses DefaultEnforcer with this - // Env's bash settings and permissive continuity writes. - Policy policy.Enforcer -} - -// RegisterAll registers the full tool set into reg. Side-effecting -// tools are registered only when their dependency in env is non-nil -// (e.g. generate_image needs env.ImageGen). -// -// Panics on duplicate registration — call this exactly once per -// Registry. -func RegisterAll(reg *Registry, env *Env) { - // Read-only. - reg.Register(listCharactersTool(env)) - reg.Register(getCharacterTool(env)) - reg.Register(listReferencesTool(env)) - reg.Register(getReferenceTool(env)) - reg.Register(searchTool(env)) - reg.Register(readFileTool(env)) - reg.Register(listSpacesTool(env)) - reg.Register(planWorkflowTool(env)) - reg.Register(createSpaceTool(env)) - reg.Register(getContextPacketTool(env)) - reg.Register(activateSpaceTool(env)) - reg.Register(recordDecisionTool(env)) - reg.Register(recordFeedbackTool(env)) - reg.Register(recordMemoryItemTool(env)) - reg.Register(promoteMemoryItemTool(env)) - reg.Register(createEpisodeTool(env)) - reg.Register(registerAssetTool(env)) - reg.Register(updateAssetWeightTool(env)) - reg.Register(recordCompactionTool(env)) - - // Side-effecting. - if env.Compiler != nil { - reg.Register(compileSkillTool(env)) - } - if env.ImageGen != nil { - reg.Register(generateImageTool(env)) - } - reg.Register(saveArtifactTool(env)) - reg.Register(bashTool(env)) - reg.Register(finishTool()) -} - -func (env *Env) policy() policy.Enforcer { - if env.Policy != nil { - return env.Policy - } - return policy.DefaultEnforcer{ - BashMode: projectx.BashPermissionMode(env.BashMode), - IsBashAllowed: env.IsBashAllowed, - JudgeBash: func(ctx context.Context, command, description string) policy.BashJudgement { - if env.JudgeBash == nil { - return policy.BashAsk - } - switch env.JudgeBash(ctx, command, description) { - case BashAuto: - return policy.BashAuto - case BashBlock: - return policy.BashBlock - default: - return policy.BashAsk - } - }, - } -} - -func (env *Env) beforeContinuityWrite(ctx context.Context, tool, spaceID string, raw json.RawMessage) (json.RawMessage, map[string]any, bool) { - if resp := env.policy().Check(ctx, policy.Request{ - Action: "continuity.write", - Tool: tool, - Workdir: env.Workdir, - SpaceID: spaceID, - Description: "write creative continuity state", - }); resp.Decision == policy.Deny { - return raw, map[string]any{"error": policy.ReasonOrDefault(resp.Reason, "continuity write denied by policy")}, false - } - if env.Hooks == nil { - return raw, nil, true - } - hr := env.Hooks.BeforeContinuityWrite(ctx, hooks.ContinuityWriteEvent{ - Tool: tool, - Workdir: env.Workdir, - SpaceID: spaceID, - Payload: raw, - }) - switch hr.EffectiveDecision() { - case hooks.Deny, hooks.Cancel: - return raw, map[string]any{"error": "continuity write blocked by hook: " + hr.Reason}, false - } - if len(hr.RewriteContinuityPayload) > 0 { - return hr.RewriteContinuityPayload, nil, true - } - return raw, nil, true -} - -func (env *Env) afterContinuityWrite(ctx context.Context, tool, spaceID string, raw json.RawMessage, result any, err error) { - if env.Hooks == nil { - return - } - env.Hooks.AfterContinuityWrite(ctx, hooks.ContinuityWriteEvent{ - Tool: tool, - Workdir: env.Workdir, - SpaceID: spaceID, - Payload: raw, - Result: result, - Err: err, - }) -} - -func rawSpaceID(raw json.RawMessage) (string, bool) { - var args struct { - SpaceID string `json:"space_id"` - ID string `json:"id"` - } - if err := json.Unmarshal(raw, &args); err != nil { - return "", false - } - if strings.TrimSpace(args.SpaceID) != "" { - return strings.TrimSpace(args.SpaceID), true - } - if strings.TrimSpace(args.ID) != "" { - return strings.TrimSpace(args.ID), true - } - return "", false -} - -func spID(id string) string { - return strings.TrimSpace(id) -} - -// --- read-only tools --- - -func listCharactersTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "list_characters", - Description: "List all characters registered in this project. Optional substring filter on name+description.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Optional substring to filter by"} - } - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Query string } - _ = json.Unmarshal(raw, &args) - items, err := registry.List(env.Workdir, registry.KindCharacter) - if err != nil { - return nil, err - } - out := []map[string]any{} - for _, it := range items { - if args.Query != "" { - hay := strings.ToLower(it.Name + " " + it.Description) - if !strings.Contains(hay, strings.ToLower(args.Query)) { - continue - } - } - out = append(out, map[string]any{ - "slug": it.Slug, - "name": it.Name, - "description": it.Description, - "tags": it.Tags, - "images": len(it.Images), - }) - } - return out, nil - }, - } -} - -func getCharacterTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "get_character", - Description: "Fetch a character's full details, including absolute paths to its portrait images so you can pass them as references to generate_image.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "slug": {"type": "string"} - }, - "required": ["slug"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Slug string } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - it, err := registry.Get(env.Workdir, registry.KindCharacter, args.Slug) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return characterJSON(env.Workdir, it), nil - }, - } -} - -func listReferencesTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "list_references", - Description: "List all reference images in this project — typically named scenes, lighting setups, or composition templates.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "query": {"type": "string"} - } - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Query string } - _ = json.Unmarshal(raw, &args) - items, err := registry.List(env.Workdir, registry.KindReference) - if err != nil { - return nil, err - } - out := []map[string]any{} - for _, it := range items { - if args.Query != "" { - hay := strings.ToLower(it.Name + " " + it.Description) - if !strings.Contains(hay, strings.ToLower(args.Query)) { - continue - } - } - out = append(out, map[string]any{ - "slug": it.Slug, - "name": it.Name, - "description": it.Description, - "tags": it.Tags, - "images": len(it.Images), - }) - } - return out, nil - }, - } -} - -func getReferenceTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "get_reference", - Description: "Fetch a reference image's full details, including its absolute on-disk path so you can pass it to generate_image.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "slug": {"type": "string"} - }, - "required": ["slug"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Slug string } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - it, err := registry.Get(env.Workdir, registry.KindReference, args.Slug) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return referenceJSON(env.Workdir, it), nil - }, - } -} - -func searchTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "search", - Description: "Grep across the project's characters / references / materials. Supports tag:foo, kind:character, -negative, \"quoted phrases\". Returns a ranked list.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "query": {"type": "string"} - }, - "required": ["query"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Query string } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - q, err := search.Parse(args.Query) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - hits, err := search.Run(env.Workdir, q) - if err != nil { - return nil, err - } - out := []map[string]any{} - for _, h := range hits { - out = append(out, map[string]any{ - "kind": string(h.Item.Kind), - "slug": h.Item.Slug, - "name": h.Item.Name, - "description": h.Item.Description, - "tags": h.Item.Tags, - "score": h.Score, - }) - } - return out, nil - }, - } -} - -func readFileTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "read_file", - Description: "Read a UTF-8 text file from inside the project workdir. Paths are resolved relative to the project root and may not escape it.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "path": {"type": "string"} - }, - "required": ["path"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Path string } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - abs, err := safeJoin(env.Workdir, args.Path) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - b, err := os.ReadFile(abs) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return map[string]any{"path": args.Path, "content": string(b)}, nil - }, - } -} - -func listSpacesTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "list_spaces", - Description: "List or search creative continuity spaces. Use this before starting a long-running series, continuing one, or deciding whether a request belongs to an existing space.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Optional search query over space id, name, description, platform, audience, and tags"} - } - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Query string } - _ = json.Unmarshal(raw, &args) - if strings.TrimSpace(args.Query) != "" { - hits, err := continuity.SearchSpaces(env.Workdir, args.Query) - if err != nil { - return nil, err - } - out := []map[string]any{} - for _, h := range hits { - out = append(out, map[string]any{ - "score": h.Score, - "id": h.Space.ID, - "name": h.Space.Name, - "status": h.Space.Status, - "platform": h.Space.Platform, - "audience": h.Space.Audience, - "description": h.Space.Description, - "tags": h.Space.Tags, - }) - } - return out, nil - } - spaces, err := continuity.ListSpaces(env.Workdir) - if err != nil { - return nil, err - } - out := []map[string]any{} - for _, sp := range spaces { - out = append(out, map[string]any{ - "id": sp.ID, - "name": sp.Name, - "status": sp.Status, - "platform": sp.Platform, - "audience": sp.Audience, - "description": sp.Description, - "tags": sp.Tags, - }) - } - return out, nil - }, - } -} - -func planWorkflowTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "plan_creator_workflow", - Description: "Plan how to handle the user's creative request: start a new space, confirm a draft space, or continue an active space. Use before making durable continuity writes when the workflow is ambiguous.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "intent": {"type": "string"} - }, - "required": ["intent"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct{ Intent string } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - p, err := continuity.PlanWorkflow(env.Workdir, args.Intent) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return p, nil - }, - } -} - -func createSpaceTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "create_space", - Description: "Create a draft creative continuity space for a durable series/account/campaign context. This tool stores only provisional assumptions, not confirmed canon. Ask concise clarification questions before treating assumptions as long-term rules.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "id": {"type": "string", "description": "kebab-case space id"}, - "name": {"type": "string"}, - "platform": {"type": "string"}, - "audience": {"type": "string"}, - "description": {"type": "string"}, - "tags": {"type": "array", "items": {"type": "string"}}, - "assumptions": {"type": "string", "description": "Provisional setup assumptions and open questions. Low authority until the user confirms them."} - }, - "required": ["id", "name"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct { - ID string - Name string - Platform string - Audience string - Description string - Tags []string - Assumptions string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - raw, blocked, ok := env.beforeContinuityWrite(ctx, "create_space", args.ID, raw) - if !ok { - return blocked, nil - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - sp, err := continuity.CreateSpace(env.Workdir, continuity.CreateSpaceOptions{ - ID: args.ID, - Name: args.Name, - Platform: args.Platform, - Audience: args.Audience, - Description: args.Description, - Tags: args.Tags, - Assumptions: args.Assumptions, - }) - env.afterContinuityWrite(ctx, "create_space", spID(args.ID), raw, sp, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return map[string]any{ - "id": sp.ID, - "name": sp.Name, - "status": sp.Status, - "description": sp.Description, - "dir": continuity.SpaceDir(env.Workdir, sp.ID), - "next_action": "Ask the user to confirm or correct the provisional assumptions before recording decisions or treating them as canon.", - }, nil - }, - } -} - -func getContextPacketTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "get_context_packet", - Description: "Fetch the model-readable continuity context packet for a creative space: authority notes, provisional assumptions, confirmed canon, memory, plan, recent decisions, feedback, episodes, and assets. Use before producing or continuing content in that space.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "query": {"type": "string", "description": "Current creative intent or retrieval hint for ranking assets"}, - "max_decisions": {"type": "number"}, - "max_feedback": {"type": "number"}, - "max_episodes": {"type": "number"}, - "max_assets": {"type": "number"} - }, - "required": ["space_id"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct { - SpaceID string `json:"space_id"` - Query string - MaxDecisions int `json:"max_decisions"` - MaxFeedback int `json:"max_feedback"` - MaxEpisodes int `json:"max_episodes"` - MaxAssets int `json:"max_assets"` - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - projectID := "" - if env.Project != nil { - projectID = env.Project.ID - } - p, err := continuity.BuildSelectedContextPacket(env.Workdir, projectID, args.SpaceID, continuity.SelectionOptions{ - Query: args.Query, - MaxDecisions: args.MaxDecisions, - MaxFeedback: args.MaxFeedback, - MaxEpisodes: args.MaxEpisodes, - MaxAssets: args.MaxAssets, - }) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return p, nil - }, - } -} - -func activateSpaceTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "activate_space", - Description: "Activate a draft creative space after the user explicitly confirms the core direction. Records the confirmation as a decision. Use before creating durable episodes in a new space.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "decision": {"type": "string", "description": "What the user confirmed"}, - "reason": {"type": "string"}, - "weight": {"type": "number"} - }, - "required": ["space_id", "decision"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "activate_space", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - Decision string - Reason string - Weight float64 - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - sp, d, err := continuity.ActivateSpace(env.Workdir, args.SpaceID, continuity.Decision{ - Decision: args.Decision, - Reason: args.Reason, - Weight: args.Weight, - }) - env.afterContinuityWrite(ctx, "activate_space", args.SpaceID, raw, sp, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return map[string]any{ - "id": sp.ID, - "name": sp.Name, - "status": sp.Status, - "decision": d, - }, nil - }, - } -} - -func recordDecisionTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "record_decision", - Description: "Record a user-confirmed continuity decision for a creative space. Do not use for guesses; only record decisions the user accepted or clearly instructed.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "scope": {"type": "string", "description": "space, episode, asset, style, character, scene"}, - "target": {"type": "string"}, - "decision": {"type": "string"}, - "reason": {"type": "string"}, - "weight": {"type": "number"} - }, - "required": ["space_id", "decision"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "record_decision", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - Scope string - Target string - Decision string - Reason string - Weight float64 - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - d, err := continuity.RecordDecision(env.Workdir, args.SpaceID, continuity.Decision{ - Scope: args.Scope, - Target: args.Target, - Decision: args.Decision, - Reason: args.Reason, - Weight: args.Weight, - }) - env.afterContinuityWrite(ctx, "record_decision", args.SpaceID, raw, d, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return d, nil - }, - } -} - -func recordFeedbackTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "record_feedback", - Description: "Record user or audience feedback for a creative space so future production can adapt strategy, pacing, style, assets, or planning.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "episode_id": {"type": "string"}, - "source": {"type": "string"}, - "signal": {"type": "string", "description": "normalized signal, e.g. pace_too_fast, style_worked, asset_drift"}, - "evidence": {"type": "string"}, - "recommendation": {"type": "string"} - }, - "required": ["space_id", "signal"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "record_feedback", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - EpisodeID string `json:"episode_id"` - Source string - Signal string - Evidence string - Recommendation string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - f, err := continuity.RecordFeedback(env.Workdir, args.SpaceID, continuity.Feedback{ - EpisodeID: args.EpisodeID, - Source: args.Source, - Signal: args.Signal, - Evidence: args.Evidence, - Recommendation: args.Recommendation, - }) - env.afterContinuityWrite(ctx, "record_feedback", args.SpaceID, raw, f, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return f, nil - }, - } -} - -func recordMemoryItemTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "record_memory_item", - Description: "Record a provisional memory item for a creative space. Use for observations, reusable patterns, weak preferences, or unresolved continuity notes that should not become confirmed canon yet.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "id": {"type": "string"}, - "kind": {"type": "string", "description": "observation, pattern, preference, risk, open_question"}, - "scope": {"type": "string"}, - "target": {"type": "string"}, - "content": {"type": "string"}, - "source": {"type": "string"}, - "weight": {"type": "number"}, - "status": {"type": "string", "description": "provisional, active, promoted, rejected"} - }, - "required": ["space_id", "content"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "record_memory_item", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - ID string - Kind string - Scope string - Target string - Content string - Source string - Weight float64 - Status string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - item, err := continuity.RecordMemoryItem(env.Workdir, args.SpaceID, continuity.MemoryItem{ - ID: args.ID, - Kind: args.Kind, - Scope: args.Scope, - Target: args.Target, - Content: args.Content, - Source: args.Source, - Weight: args.Weight, - Status: args.Status, - }) - env.afterContinuityWrite(ctx, "record_memory_item", args.SpaceID, raw, item, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return item, nil - }, - } -} - -func promoteMemoryItemTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "promote_memory_item", - Description: "Promote a provisional memory item into a user-confirmed continuity decision. Use only after the user explicitly confirms the memory should become durable guidance.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "item_id": {"type": "string"}, - "decision": {"type": "string"}, - "reason": {"type": "string"}, - "target": {"type": "string"} - }, - "required": ["space_id", "item_id", "decision"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "promote_memory_item", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - ItemID string `json:"item_id"` - Decision string - Reason string - Target string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - d, err := continuity.PromoteMemoryItem(env.Workdir, args.SpaceID, continuity.MemoryPromotion{ - ItemID: args.ItemID, - Decision: args.Decision, - Reason: args.Reason, - Target: args.Target, - }) - env.afterContinuityWrite(ctx, "promote_memory_item", args.SpaceID, raw, d, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return d, nil - }, - } -} - -func createEpisodeTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "create_episode", - Description: "Create or register an episode under a creative space. Use for durable production units such as daily posts, videos, chapters, or content installments.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "id": {"type": "string"}, - "title": {"type": "string"}, - "topic": {"type": "string"}, - "status": {"type": "string"}, - "brief": {"type": "string", "description": "Brief markdown"} - }, - "required": ["space_id", "topic"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "create_episode", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - ID string - Title string - Topic string - Status string - Brief string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - ep, err := continuity.CreateEpisode(env.Workdir, args.SpaceID, continuity.Episode{ - ID: args.ID, - Title: args.Title, - Topic: args.Topic, - Status: args.Status, - Brief: args.Brief, - }) - env.afterContinuityWrite(ctx, "create_episode", args.SpaceID, raw, ep, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return ep, nil - }, - } -} - -func registerAssetTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "register_asset", - Description: "Register a reusable continuity asset under a creative space. Assets can be images, backgrounds, characters, props, typography rules, prompt fragments, shot specs, masks, or PSD/layered files.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "id": {"type": "string"}, - "kind": {"type": "string"}, - "status": {"type": "string", "description": "active, canonical, experimental, rejected, archived"}, - "description": {"type": "string"}, - "reuse_policy": {"type": "string"}, - "files": {"type": "array", "items": {"type": "string"}}, - "tags": {"type": "array", "items": {"type": "string"}}, - "weight": {"type": "number"} - }, - "required": ["space_id", "description"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "register_asset", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - ID string - Kind string - Status string - Description string - ReusePolicy string `json:"reuse_policy"` - Files []string - Tags []string - Weight float64 - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - a, err := continuity.RegisterAsset(env.Workdir, args.SpaceID, continuity.Asset{ - ID: args.ID, - Kind: args.Kind, - Status: args.Status, - Description: args.Description, - ReusePolicy: args.ReusePolicy, - Files: args.Files, - Tags: args.Tags, - Weight: args.Weight, - }) - env.afterContinuityWrite(ctx, "register_asset", args.SpaceID, raw, a, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return a, nil - }, - } -} - -func updateAssetWeightTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "update_asset_weight", - Description: "Adjust a reusable continuity asset's weight or status after user/audience feedback. Use higher weights for assets that should be reused more often; lower or archive assets that drift or perform poorly.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "asset_id": {"type": "string"}, - "weight": {"type": "number"}, - "status": {"type": "string", "description": "active, canonical, experimental, rejected, archived"} - }, - "required": ["space_id", "asset_id", "weight"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "update_asset_weight", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - AssetID string `json:"asset_id"` - Weight float64 - Status string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - a, err := continuity.UpdateAssetWeight(env.Workdir, args.SpaceID, args.AssetID, args.Weight, args.Status) - env.afterContinuityWrite(ctx, "update_asset_weight", args.SpaceID, raw, a, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return a, nil - }, - } -} - -func recordCompactionTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "record_compaction", - Description: "Record a compact summary of a creative space's long-running state. Use after reviewing selected context or when a series has accumulated enough history to need a reusable summary.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "summary": {"type": "string"}, - "scope": {"type": "string"} - }, - "required": ["space_id", "summary"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - spaceID, _ := rawSpaceID(raw) - raw, blocked, ok := env.beforeContinuityWrite(ctx, "record_compaction", spaceID, raw) - if !ok { - return blocked, nil - } - var args struct { - SpaceID string `json:"space_id"` - Summary string - Scope string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - c, err := continuity.RecordSpaceCompaction(env.Workdir, args.SpaceID, continuity.SpaceCompaction{ - Summary: args.Summary, - Scope: args.Scope, - }) - env.afterContinuityWrite(ctx, "record_compaction", args.SpaceID, raw, c, err) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - return c, nil - }, - } -} - -// --- side-effecting tools --- - -func compileSkillTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "compile_skill", - Description: "Compile a skillplus package and return its compiled prompt + output schema. Pass the BARE skill slug (e.g. \"brand-logo\"), not \"skillplus:brand-logo\".", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "skill": { - "type": "string", - "description": "Bare skill slug (e.g. \"brand-logo\", \"food-street-realism\") OR an absolute path to a .skillplus directory. Do NOT prefix with \"skillplus:\"." - }, - "locale": { - "type": "string", - "description": "Locale to compile for. Allowed: \"zh-CN\" or \"en\". Default zh-CN.", - "enum": ["zh-CN", "en"] - }, - "model_profile": { - "type": "string", - "description": "Per-skill prompt overlay slug. Default \"gpt-image-family\"." - }, - "vars": {"type": "object", "additionalProperties": {"type": "string"}} - }, - "required": ["skill"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct { - Skill string - Locale string - ModelProfile string `json:"model_profile"` - Vars map[string]string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - // Be forgiving: strip "skillplus:" / "path:" prefixes the - // model might have learned from older docs. The CLI only - // accepts a bare slug or an absolute path. - args.Skill = strings.TrimPrefix(args.Skill, "skillplus:") - args.Skill = strings.TrimPrefix(args.Skill, "path:") - - args.Locale = normalizeLocale(args.Locale) - if args.ModelProfile == "" { - args.ModelProfile = "gpt-image-family" - } - compiled, err := env.Compiler.CompileRaw(ctx, &skillplus.CompileRequest{ - PackagePath: args.Skill, - Target: "openmelon", - ModelProfile: args.ModelProfile, - Locale: args.Locale, - Vars: args.Vars, - }) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - var compiledMap map[string]any - if err := json.Unmarshal(compiled, &compiledMap); err != nil { - return map[string]any{"error": "compiler returned invalid JSON"}, nil - } - return compiledMap, nil - }, - } -} - -// normalizeLocale maps loose locale strings the model might emit -// ("zh", "chinese", "cn") to the canonical values skillplus accepts -// ("zh-CN", "en"). Empty / unknown defaults to zh-CN. -func normalizeLocale(in string) string { - v := strings.ToLower(strings.TrimSpace(in)) - switch v { - case "", "zh", "zh-cn", "zh_cn", "chinese", "cn": - return "zh-CN" - case "en", "en-us", "english", "us": - return "en" - } - // Unknown — pass through, let skillplus error if it's truly invalid. - return in -} - -func generateImageTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "generate_image", - Description: "Generate a single image and save it into the visible project outputs directory for the current session. Include continuity constraints for characters, scenes, typography, layout, and style in the prompt. Optionally pass reference_images (absolute paths) to anchor the result to known characters or scenes.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "prompt": {"type": "string"}, - "reference_images": { - "type": "array", - "items": {"type": "string", "description": "absolute path"} - }, - "size": {"type": "string", "description": "WxH, vendor-default if omitted"}, - "label": {"type": "string", "description": "short label saved into the session metadata, e.g. \"draft-1\""}, - "output_dir": {"type": "string", "description": "optional project-relative visible directory for this output; defaults to outputs/sessions/. Do not use .openmelon."} - }, - "required": ["prompt"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct { - Prompt string - ReferenceImages []string `json:"reference_images"` - Size string - Label string - OutputDir string `json:"output_dir"` - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - refs := make([][]byte, 0, len(args.ReferenceImages)) - for _, p := range args.ReferenceImages { - b, err := os.ReadFile(p) - if err != nil { - return map[string]any{"error": fmt.Sprintf("read reference %s: %v", p, err)}, nil - } - refs = append(refs, b) - } - res, err := env.ImageGen.Generate(ctx, imagegen.GenerateOptions{ - Prompt: args.Prompt, - Size: args.Size, - ReferenceImages: refs, - }) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - label := args.Label - if label == "" { - label = "image" - } - ts := time.Now().UTC().Format("150405") - ext := extensionFor(res.ContentType) - outName := fmt.Sprintf("%s-%s%s", label, ts, ext) - fallback := env.OutputDir - if fallback == "" { - fallback = projectx.OutputDir(env.Workdir) - } - outDir, err := projectx.ResolveOutputDir(env.Workdir, args.OutputDir, fallback) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - outPath := filepath.Join(outDir, outName) - if err := os.MkdirAll(outDir, 0o755); err != nil { - return nil, err - } - if err := os.WriteFile(outPath, res.Data, 0o644); err != nil { - return nil, err - } - h := sha256.Sum256(res.Data) - return map[string]any{ - "path": outPath, - "label": label, - "sha256": hex.EncodeToString(h[:]), - "size_bytes": res.SizeBytes, - "prompt": args.Prompt, - }, nil - }, - } -} - -func saveArtifactTool(env *Env) Tool { - return Tool{ - Spec: Spec{ - Name: "save_artifact", - Description: "Promote a generated image to a permanent visible project artifact under outputs/artifacts///, or a project-relative output_dir if specified. Never write final deliverables under .openmelon.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "slug": {"type": "string", "description": "kebab-case label for this artifact bucket"}, - "image_path": {"type": "string", "description": "absolute path returned by an earlier generate_image call"}, - "prompt": {"type": "string", "description": "the prompt used; recorded for provenance"}, - "output_dir": {"type": "string", "description": "optional project-relative visible directory for this artifact. Do not use .openmelon."} - }, - "required": ["slug", "image_path"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - var args struct { - Slug string - ImagePath string `json:"image_path"` - Prompt string - OutputDir string `json:"output_dir"` - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - if err := registry.ValidateSlug(args.Slug); err != nil { - return map[string]any{"error": err.Error()}, nil - } - b, err := os.ReadFile(args.ImagePath) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - ts := time.Now().UTC().Format("20060102-150405") - fallback := projectx.ArtifactOutputDir(env.Workdir, args.Slug, ts) - outDir, err := projectx.ResolveOutputDir(env.Workdir, args.OutputDir, fallback) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - if err := os.MkdirAll(outDir, 0o755); err != nil { - return nil, err - } - ext := filepath.Ext(args.ImagePath) - if ext == "" { - ext = ".png" - } - outPath := filepath.Join(outDir, "image"+ext) - if err := os.WriteFile(outPath, b, 0o644); err != nil { - return nil, err - } - if args.Prompt != "" { - _ = os.WriteFile(filepath.Join(outDir, "prompt.txt"), []byte(args.Prompt), 0o644) - } - h := sha256.Sum256(b) - return map[string]any{ - "path": outPath, - "sha256": hex.EncodeToString(h[:]), - }, nil - }, - } -} - -func finishTool() Tool { - return Tool{ - Spec: Spec{ - Name: "finish", - Description: "Signal that you've completed the task. Provide a one- to two-paragraph summary the user will see, plus any final artifact paths.", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "summary": {"type": "string"}, - "artifacts": { - "type": "array", - "items": {"type": "string"}, - "description": "Absolute paths to final outputs" - } - }, - "required": ["summary"] - }`), - }, - Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { - // finish is a sentinel — its return value is read by the - // runtime which then exits the loop. - var args struct { - Summary string - Artifacts []string - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("invalid args: %w", err) - } - return map[string]any{"summary": args.Summary, "artifacts": args.Artifacts, "ok": true}, nil - }, - } -} - -// --- helpers --- - -func characterJSON(workdir string, it *registry.Item) map[string]any { - out := map[string]any{ - "slug": it.Slug, - "name": it.Name, - "description": it.Description, - "tags": it.Tags, - "extra": it.Extra, - } - out["image_paths"] = absoluteImagePaths(workdir, registry.KindCharacter, it) - return out -} - -func referenceJSON(workdir string, it *registry.Item) map[string]any { - out := map[string]any{ - "slug": it.Slug, - "name": it.Name, - "description": it.Description, - "tags": it.Tags, - "extra": it.Extra, - } - out["image_paths"] = absoluteImagePaths(workdir, registry.KindReference, it) - return out -} - -func absoluteImagePaths(workdir string, kind registry.Kind, it *registry.Item) []string { - if len(it.Images) == 0 { - return nil - } - base := filepath.Join(projectx.StateDir(workdir), kindDir(kind), it.Slug) - out := make([]string, len(it.Images)) - for i, n := range it.Images { - out[i] = filepath.Join(base, n) - } - return out -} - -// kindDir returns the on-disk subdirectory for a kind. Mirrors registry's -// internal mapping but kept local so we don't expose an internal helper. -func kindDir(k registry.Kind) string { - switch k { - case registry.KindCharacter: - return "characters" - case registry.KindReference: - return "references" - case registry.KindMaterial: - return "materials" - } - return "" -} - -// safeJoin returns base/path as an absolute path, returning an error if -// path tries to escape base via "..". -func safeJoin(base, path string) (string, error) { - clean := filepath.Clean(path) - if filepath.IsAbs(clean) { - // Allow absolute paths only if they live under base. - absBase, _ := filepath.Abs(base) - if !strings.HasPrefix(clean, absBase+string(filepath.Separator)) && clean != absBase { - return "", fmt.Errorf("path %q escapes project workdir", path) - } - return clean, nil - } - out := filepath.Join(base, clean) - rel, err := filepath.Rel(base, out) - if err != nil { - return "", err - } - if strings.HasPrefix(rel, "..") { - return "", fmt.Errorf("path %q escapes project workdir", path) - } - return out, nil -} - -func extensionFor(contentType string) string { - switch contentType { - case "image/png": - return ".png" - case "image/jpeg": - return ".jpg" - case "image/webp": - return ".webp" - } - return ".png" -} diff --git a/internal/tools/tools.go b/internal/tools/tools.go deleted file mode 100644 index 3fdfa0c..0000000 --- a/internal/tools/tools.go +++ /dev/null @@ -1,141 +0,0 @@ -// Package tools is openmelon's runtime tool registry. -// -// A Tool is one callable function the model can invoke. The runtime -// (package runtime) wires the registry to an LLM, drives the -// chat-with-tools loop, and dispatches tool calls back to handlers in -// this package. -// -// Tool design rules: -// -// - Every tool has a JSON-schema parameter spec (kept hand-written for -// control over what the model sees in the wire prompt). -// - Every tool returns a JSON-encoded result. Errors are returned as -// JSON too, so the model can read the error and recover instead of -// crashing the loop. -// - No tool spawns subprocesses or makes network calls beyond what's -// already authorized for openmelon (skillplus subprocess, image -// generator, vbox-cli for publish). -// - Tools see the project workdir and registry — no escape to other -// paths on disk. -package tools - -import ( - "context" - "encoding/json" - "fmt" -) - -// Spec is a tool's machine-readable contract: name, description, JSON -// schema for parameters. Sent to the model as part of every Chat call. -type Spec struct { - Name string - Description string - // Parameters is a JSON Schema object. Keep it small. - Parameters json.RawMessage -} - -// Handler is the Go side of a tool call. It receives the raw arguments -// the model emitted (a JSON object as produced by the vendor's tool-call -// wire format) and returns a JSON-marshalable value or an error. -// -// Errors should be returned as values for "the model gave us bad input" -// situations — the runtime relays them back as a tool message so the -// model can self-correct. Non-recoverable errors (file IO failure, -// network blow-up) should be returned as the second return value. -type Handler func(ctx context.Context, raw json.RawMessage) (any, error) - -// Tool ties a Spec and a Handler together. -type Tool struct { - Spec Spec - Handler Handler -} - -// Registry collects tools by name. The runtime asks the registry for -// (a) the list of Specs to send to the model, and (b) the Handler for a -// given name when dispatching a tool call. -type Registry struct { - tools map[string]Tool - order []string // preserves insertion order for stable wire prompts -} - -// NewRegistry returns an empty registry. -func NewRegistry() *Registry { - return &Registry{tools: map[string]Tool{}} -} - -// Register adds a tool. Names must be unique; re-registering panics so -// nobody silently shadows a tool. -func (r *Registry) Register(t Tool) { - if _, ok := r.tools[t.Spec.Name]; ok { - panic(fmt.Sprintf("tools: duplicate registration: %s", t.Spec.Name)) - } - r.tools[t.Spec.Name] = t - r.order = append(r.order, t.Spec.Name) -} - -// Specs returns all registered specs in registration order. -func (r *Registry) Specs() []Spec { - out := make([]Spec, 0, len(r.order)) - for _, name := range r.order { - out = append(out, r.tools[name].Spec) - } - return out -} - -// Names returns all registered tool names in registration order. -// Useful for tests and for the system prompt that lists what's -// available. -func (r *Registry) Names() []string { - out := make([]string, len(r.order)) - copy(out, r.order) - return out -} - -// Dispatch calls the handler for name with the raw arguments. Returns -// ErrUnknownTool if the name isn't registered. -func (r *Registry) Dispatch(ctx context.Context, name string, args json.RawMessage) (any, error) { - t, ok := r.tools[name] - if !ok { - return nil, fmt.Errorf("%w: %q (available: %v)", ErrUnknownTool, name, r.Names()) - } - return t.Handler(ctx, args) -} - -// ErrUnknownTool is returned by Dispatch when the requested tool name -// is not in the registry. -var ErrUnknownTool = errInf("unknown tool") - -// ApprovalRequest is what side-effecting tools (notably bash) hand to -// Env.Approve before running. The TUI renders these as a confirmation -// panel; headless callers can default-deny. -type ApprovalRequest struct { - Tool string // "bash" - Command string - Description string - // Binary is the first executable name parsed from Command. The - // approval modal uses it to label the "Yes always for " - // option. - Binary string -} - -// ApprovalDecision is what the user (or auto-approval rule) returns -// from Env.Approve. Approved=false → tool aborts; Approved=true, -// Always=true → also add Binary to the per-session allowlist so future -// calls with the same binary skip the modal. -type ApprovalDecision struct { - Approved bool - Always bool -} - -// BashJudgement is the safety classifier's verdict on a bash command. -type BashJudgement int - -const ( - BashAsk BashJudgement = iota // default; show the modal - BashAuto // safe (read-only inspection); run without asking - BashBlock // destructive / exfiltrating; refuse without asking -) - -type errInf string - -func (e errInf) Error() string { return string(e) } diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go deleted file mode 100644 index 61594a7..0000000 --- a/internal/tools/tools_test.go +++ /dev/null @@ -1,495 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/continuity" - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/imagegen" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/registry" -) - -func TestRegistry_RegisterDispatchAndUnknown(t *testing.T) { - reg := NewRegistry() - reg.Register(Tool{ - Spec: Spec{Name: "echo", Description: "x", Parameters: json.RawMessage(`{}`)}, - Handler: func(_ context.Context, raw json.RawMessage) (any, error) { - return map[string]any{"got": string(raw)}, nil - }, - }) - got, err := reg.Dispatch(context.Background(), "echo", json.RawMessage(`{"x":1}`)) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - if m := got.(map[string]any); m["got"] != `{"x":1}` { - t.Errorf("unexpected: %+v", m) - } - _, err = reg.Dispatch(context.Background(), "ghost", nil) - if !errors.Is(err, ErrUnknownTool) { - t.Errorf("expected ErrUnknownTool, got %v", err) - } -} - -func TestRegistry_DuplicatePanics(t *testing.T) { - reg := NewRegistry() - reg.Register(Tool{Spec: Spec{Name: "a", Parameters: json.RawMessage(`{}`)}, Handler: noopHandler}) - defer func() { - if r := recover(); r == nil { - t.Error("expected panic on duplicate registration") - } - }() - reg.Register(Tool{Spec: Spec{Name: "a", Parameters: json.RawMessage(`{}`)}, Handler: noopHandler}) -} - -func TestSafeJoinAllowsRelativeUnderBase(t *testing.T) { - wd := t.TempDir() - out, err := safeJoin(wd, "subdir/file.txt") - if err != nil { - t.Fatalf("safeJoin: %v", err) - } - if !strings.HasPrefix(out, wd) { - t.Errorf("not under base: %q", out) - } -} - -func TestSafeJoinRejectsEscape(t *testing.T) { - wd := t.TempDir() - if _, err := safeJoin(wd, "../../etc/passwd"); err == nil { - t.Error("expected error for parent-dir escape") - } - if _, err := safeJoin(wd, "/etc/passwd"); err == nil { - t.Error("expected error for absolute path outside base") - } -} - -func TestListCharactersTool_FiltersBySubstring(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - for _, c := range []registry.AddOptions{ - {Kind: registry.KindCharacter, Slug: "lao-wang", Name: "Lao Wang", Description: "vendor"}, - {Kind: registry.KindCharacter, Slug: "xiao-li", Name: "Xiao Li", Description: "photographer"}, - } { - if _, err := registry.Add(wd, c); err != nil { - t.Fatalf("registry add: %v", err) - } - } - env := &Env{Workdir: wd} - tool := listCharactersTool(env) - res, err := tool.Handler(context.Background(), json.RawMessage(`{"query":"vendor"}`)) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - list := res.([]map[string]any) - if len(list) != 1 || list[0]["slug"] != "lao-wang" { - t.Errorf("unexpected hits: %+v", list) - } -} - -func TestGetCharacterTool_ReturnsAbsoluteImagePaths(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - srcImg := wd + "/portrait.png" - if err := writeMinPNG(srcImg); err != nil { - t.Fatalf("write png: %v", err) - } - if _, err := registry.Add(wd, registry.AddOptions{ - Kind: registry.KindCharacter, Slug: "lao-wang", Name: "Lao Wang", - ImagePath: srcImg, ImageName: "portrait", - }); err != nil { - t.Fatalf("registry add: %v", err) - } - env := &Env{Workdir: wd} - res, err := getCharacterTool(env).Handler(context.Background(), json.RawMessage(`{"slug":"lao-wang"}`)) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - m := res.(map[string]any) - paths, ok := m["image_paths"].([]string) - if !ok || len(paths) != 1 { - t.Fatalf("image_paths: %+v", m["image_paths"]) - } - if !strings.HasPrefix(paths[0], wd) { - t.Errorf("not absolute under wd: %q", paths[0]) - } -} - -func TestSearchTool_ReturnsRankedHits(t *testing.T) { - wd := t.TempDir() - if _, err := projectx.Init(wd, "ai-talks", "AI Talks"); err != nil { - t.Fatalf("project init: %v", err) - } - if _, err := registry.Add(wd, registry.AddOptions{ - Kind: registry.KindCharacter, Slug: "lao-wang", Name: "Lao Wang", - Description: "vendor", Tags: []string{"vendor"}, - }); err != nil { - t.Fatalf("registry add: %v", err) - } - res, err := searchTool(&Env{Workdir: wd}).Handler(context.Background(), json.RawMessage(`{"query":"vendor"}`)) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - list := res.([]map[string]any) - if len(list) != 1 || list[0]["slug"] != "lao-wang" { - t.Errorf("unexpected hits: %+v", list) - } -} - -func TestContinuityTools_CreateSpaceAndContextPacket(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - env := &Env{Workdir: wd, Project: proj} - if _, err := createSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "id":"tennis-anime", - "name":"Tennis Anime", - "platform":"short-video", - "audience":"beginners", - "description":"Anime tennis lessons", - "tags":["tennis","anime"], - "assumptions":"# Assumptions\n\n- Maybe use a playful tone.\n" - }`)); err != nil { - t.Fatalf("create_space: %v", err) - } - if _, err := recordDecisionTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-anime", - "decision":"Use clean anime illustration.", - "target":"visual_style" - }`)); err != nil { - t.Fatalf("record_decision: %v", err) - } - res, err := getContextPacketTool(env).Handler(context.Background(), json.RawMessage(`{"space_id":"tennis-anime"}`)) - if err != nil { - t.Fatalf("get_context_packet: %v", err) - } - b, _ := json.Marshal(res) - if !strings.Contains(string(b), "tennis-anime") || !strings.Contains(string(b), "clean anime") { - t.Fatalf("packet missing continuity state: %s", string(b)) - } - if strings.Contains(string(b), "Keep it playful") { - t.Fatalf("create_space assumptions should not be promoted to canon: %s", string(b)) - } -} - -func TestContinuityTools_DraftSpaceRequiresActivationBeforeEpisode(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - env := &Env{Workdir: wd, Project: proj} - if _, err := createSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "id":"tennis-lessons", - "name":"Tennis Lessons" - }`)); err != nil { - t.Fatalf("create_space: %v", err) - } - res, err := createEpisodeTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "topic":"First lesson" - }`)) - if err != nil { - t.Fatalf("create_episode dispatch: %v", err) - } - b, _ := json.Marshal(res) - if !strings.Contains(string(b), "space tennis-lessons is draft") { - t.Fatalf("expected draft rejection, got %s", string(b)) - } - if _, err := activateSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "decision":"User confirmed this should become a continuing tennis lesson series." - }`)); err != nil { - t.Fatalf("activate_space: %v", err) - } - res, err = createEpisodeTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "id":"first-lesson", - "topic":"First lesson" - }`)) - if err != nil { - t.Fatalf("create_episode after activation: %v", err) - } - b, _ = json.Marshal(res) - if !strings.Contains(string(b), "first-lesson") { - t.Fatalf("expected episode, got %s", string(b)) - } -} - -func TestContinuityToolsUseHooksForWrites(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - h := &continuityHookRecorder{} - env := &Env{Workdir: wd, Project: proj, Hooks: h} - if _, err := createSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "id":"tennis-lessons", - "name":"Tennis Lessons" - }`)); err != nil { - t.Fatalf("create_space: %v", err) - } - if len(h.before) != 1 || h.before[0] != "create_space:tennis-lessons" { - t.Fatalf("before hooks: %+v", h.before) - } - if len(h.after) != 1 || h.after[0] != "create_space:tennis-lessons" { - t.Fatalf("after hooks: %+v", h.after) - } -} - -func TestContinuityHookCanDenyWrite(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - env := &Env{Workdir: wd, Project: proj, Hooks: &continuityHookRecorder{deny: true}} - res, err := createSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "id":"tennis-lessons", - "name":"Tennis Lessons" - }`)) - if err != nil { - t.Fatalf("create_space: %v", err) - } - b, _ := json.Marshal(res) - if !strings.Contains(string(b), "continuity write blocked by hook") { - t.Fatalf("expected hook denial, got %s", string(b)) - } -} - -func TestUpdateAssetWeightToolReranksAssets(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - env := &Env{Workdir: wd, Project: proj} - if _, err := createSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "id":"tennis-lessons", - "name":"Tennis Lessons" - }`)); err != nil { - t.Fatalf("create_space: %v", err) - } - if _, err := registerAssetTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "id":"court-a", - "description":"first court", - "weight":0.2 - }`)); err != nil { - t.Fatalf("register asset a: %v", err) - } - if _, err := registerAssetTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "id":"court-b", - "description":"second court", - "weight":1.0 - }`)); err != nil { - t.Fatalf("register asset b: %v", err) - } - if _, err := updateAssetWeightTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "asset_id":"court-a", - "weight":3.0, - "status":"canonical" - }`)); err != nil { - t.Fatalf("update weight: %v", err) - } - packet, err := continuity.BuildContextPacket(wd, proj.ID, "tennis-lessons") - if err != nil { - t.Fatalf("context packet: %v", err) - } - if len(packet.Assets) < 2 || packet.Assets[0].ID != "court-a" || packet.Assets[0].Status != "canonical" { - t.Fatalf("asset not reranked: %+v", packet.Assets) - } -} - -func TestMemoryToolsRecordAndPromote(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - env := &Env{Workdir: wd, Project: proj} - if _, err := createSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "id":"tennis-lessons", - "name":"Tennis Lessons" - }`)); err != nil { - t.Fatalf("create_space: %v", err) - } - if _, err := recordMemoryItemTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "id":"mem-tone", - "content":"Audience likes calmer explanations." - }`)); err != nil { - t.Fatalf("record memory: %v", err) - } - res, err := promoteMemoryItemTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "item_id":"mem-tone", - "decision":"Use calmer explanations for beginners." - }`)) - if err != nil { - t.Fatalf("promote memory: %v", err) - } - b, _ := json.Marshal(res) - if !strings.Contains(string(b), "calmer explanations") || !strings.Contains(string(b), `"scope":"memory"`) { - t.Fatalf("promotion result missing decision: %s", string(b)) - } -} - -func TestPlanWorkflowAndCompactionTools(t *testing.T) { - wd := t.TempDir() - proj, err := projectx.Init(wd, "creator", "Creator") - if err != nil { - t.Fatalf("project init: %v", err) - } - env := &Env{Workdir: wd, Project: proj} - res, err := planWorkflowTool(env).Handler(context.Background(), json.RawMessage(`{"intent":"continue tennis"}`)) - if err != nil { - t.Fatalf("plan workflow: %v", err) - } - plan := res.(*continuity.WorkflowPlan) - if plan.Mode != "new_space" { - t.Fatalf("plan mode = %s", plan.Mode) - } - if _, err := createSpaceTool(env).Handler(context.Background(), json.RawMessage(`{ - "id":"tennis-lessons", - "name":"Tennis Lessons" - }`)); err != nil { - t.Fatalf("create space: %v", err) - } - if _, err := recordCompactionTool(env).Handler(context.Background(), json.RawMessage(`{ - "space_id":"tennis-lessons", - "summary":"Stable tennis lesson workspace." - }`)); err != nil { - t.Fatalf("record compaction: %v", err) - } -} - -func TestGenerateImageWritesVisibleSessionOutput(t *testing.T) { - wd := t.TempDir() - img := fakeImageGenerator{} - env := &Env{ - Workdir: wd, - OutputDir: projectx.SessionOutputDir(wd, "session-1"), - ImageGen: img, - } - res, err := generateImageTool(env).Handler(context.Background(), json.RawMessage(`{ - "prompt":"draw a tennis panel", - "label":"panel" - }`)) - if err != nil { - t.Fatalf("generate_image: %v", err) - } - path := res.(map[string]any)["path"].(string) - if !strings.HasPrefix(path, filepath.Join(wd, "outputs", "sessions", "session-1")+string(filepath.Separator)) { - t.Fatalf("generated image path = %q", path) - } - if strings.Contains(path, ".openmelon") { - t.Fatalf("generated image path should be visible, got %q", path) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("generated file missing: %v", err) - } -} - -func TestGenerateImageRejectsHiddenOutputDir(t *testing.T) { - wd := t.TempDir() - env := &Env{Workdir: wd, OutputDir: projectx.OutputDir(wd), ImageGen: fakeImageGenerator{}} - res, err := generateImageTool(env).Handler(context.Background(), json.RawMessage(`{ - "prompt":"draw", - "output_dir":".openmelon/artifacts" - }`)) - if err != nil { - t.Fatalf("generate_image: %v", err) - } - body, _ := json.Marshal(res) - if !strings.Contains(string(body), "inside .openmelon") { - t.Fatalf("expected hidden-dir rejection, got %s", body) - } -} - -func TestSaveArtifactPromotesToVisibleOutputs(t *testing.T) { - wd := t.TempDir() - src := filepath.Join(projectx.SessionOutputDir(wd, "session-1"), "draft.png") - if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil { - t.Fatal(err) - } - if err := writeMinPNG(src); err != nil { - t.Fatal(err) - } - env := &Env{Workdir: wd} - res, err := saveArtifactTool(env).Handler(context.Background(), json.RawMessage(`{ - "slug":"tennis-lesson", - "image_path":"`+src+`", - "prompt":"prompt" - }`)) - if err != nil { - t.Fatalf("save_artifact: %v", err) - } - path := res.(map[string]any)["path"].(string) - if !strings.HasPrefix(path, filepath.Join(wd, "outputs", "artifacts", "tennis-lesson")+string(filepath.Separator)) { - t.Fatalf("artifact path = %q", path) - } - if strings.Contains(path, ".openmelon") { - t.Fatalf("artifact path should be visible, got %q", path) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("artifact file missing: %v", err) - } -} - -func noopHandler(_ context.Context, _ json.RawMessage) (any, error) { return nil, nil } - -func writeMinPNG(path string) error { - return os.WriteFile(path, []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13}, 0o644) -} - -type fakeImageGenerator struct{} - -func (fakeImageGenerator) Generate(_ context.Context, opts imagegen.GenerateOptions) (*imagegen.Result, error) { - return &imagegen.Result{ - Data: []byte{0x89, 'P', 'N', 'G'}, - ContentType: "image/png", - Provider: "fake", - Model: "fake-image", - Prompt: opts.Prompt, - SizeBytes: 4, - }, nil -} - -func (fakeImageGenerator) Provider() string { return "fake" } - -func (fakeImageGenerator) Model() string { return "fake-image" } - -type continuityHookRecorder struct { - hooks.NoopManager - before []string - after []string - deny bool -} - -func (h *continuityHookRecorder) BeforeContinuityWrite(_ context.Context, e hooks.ContinuityWriteEvent) hooks.HookResult { - h.before = append(h.before, e.Tool+":"+e.SpaceID) - if h.deny { - return hooks.HookResult{Decision: hooks.Deny, Reason: "test denial"} - } - return hooks.HookResult{} -} - -func (h *continuityHookRecorder) AfterContinuityWrite(_ context.Context, e hooks.ContinuityWriteEvent) hooks.HookResult { - h.after = append(h.after, e.Tool+":"+e.SpaceID) - return hooks.HookResult{} -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index dde01ce..0000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,54 +0,0 @@ -package tui - -// keys.go — key binding map. Centralized so the help line and Update() -// stay in sync. - -import "github.com/charmbracelet/bubbles/key" - -type keyMap struct { - Submit key.Binding - Newline key.Binding - Cancel key.Binding - Quit key.Binding - Help key.Binding - CopyTranscript key.Binding - ScrollU key.Binding - ScrollD key.Binding -} - -func defaultKeys() keyMap { - return keyMap{ - Submit: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("↵", "submit"), - ), - Newline: key.NewBinding( - key.WithKeys("shift+enter", "ctrl+j"), - key.WithHelp("⇧↵", "newline"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit (×2)"), - ), - Help: key.NewBinding( - key.WithKeys("ctrl+h"), - key.WithHelp("ctrl+h", "help"), - ), - CopyTranscript: key.NewBinding( - key.WithKeys("ctrl+y"), - key.WithHelp("ctrl+y", "copy transcript"), - ), - ScrollU: key.NewBinding( - key.WithKeys("pgup", "ctrl+u"), - key.WithHelp("pgup", "scroll up"), - ), - ScrollD: key.NewBinding( - key.WithKeys("pgdown", "ctrl+d"), - key.WithHelp("pgdn", "scroll down"), - ), - } -} diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go deleted file mode 100644 index 293177d..0000000 --- a/internal/tui/markdown.go +++ /dev/null @@ -1,269 +0,0 @@ -package tui - -import ( - "regexp" - "strings" - "unicode" - - "github.com/charmbracelet/x/ansi" -) - -var ( - orderedListRe = regexp.MustCompile(`^(\s*)(\d+)[.)]\s+(.*)$`) - linkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) -) - -type MarkdownRenderer interface { - Render(markdown string, width int) string -} - -type terminalMarkdownRenderer struct{} - -func newMarkdownRenderer() MarkdownRenderer { - return terminalMarkdownRenderer{} -} - -func (terminalMarkdownRenderer) Render(src string, width int) string { - return renderMarkdownWithWidth(src, width) -} - -// renderMarkdown renders the small Markdown subset the assistant most -// often emits. It is intentionally lightweight: the TUI needs readable -// terminal output without adding another parsing dependency yet. -func renderMarkdown(src string) string { - return renderMarkdownWithWidth(src, 0) -} - -func renderMarkdownPlain(src string) string { - return ansiPlain(renderMarkdownWithWidth(src, 0)) -} - -func ansiPlain(s string) string { - return ansi.Strip(s) -} - -func renderMarkdownWithWidth(src string, width int) string { - src = strings.ReplaceAll(src, "\r\n", "\n") - lines := strings.Split(src, "\n") - - var b strings.Builder - inFence := false - fenceLang := "" - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "```") { - if inFence { - inFence = false - fenceLang = "" - } else { - inFence = true - fenceLang = strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) - if fenceLang != "" { - b.WriteString(styleMarkdownCodeLang.Render(" "+fenceLang) + "\n") - } - } - continue - } - - if inFence { - b.WriteString(styleMarkdownCodeBlock.Render(" " + line)) - if i < len(lines)-1 { - b.WriteByte('\n') - } - continue - } - - if trimmed == "" { - b.WriteByte('\n') - continue - } - - switch { - case isHeading(trimmed): - level, text := splitHeading(trimmed) - if level <= 2 { - b.WriteString(styleMarkdownHeading.Render(renderInline(text))) - } else { - b.WriteString(styleMarkdownSubheading.Render(renderInline(text))) - } - case isHorizontalRule(trimmed): - b.WriteString(styleHelp.Render(ruleLine(width))) - case isTableDelimiter(trimmed): - continue - case isTableRow(trimmed): - b.WriteString(renderTableRow(trimmed)) - case strings.HasPrefix(trimmed, ">"): - text := strings.TrimSpace(strings.TrimLeft(trimmed, ">")) - b.WriteString(styleMarkdownQuote.Render("> " + renderInline(text))) - case isUnorderedList(trimmed): - text := strings.TrimSpace(trimmed[1:]) - b.WriteString(" " + styleMarkdownBullet.Render("- ") + renderInline(text)) - case orderedListRe.MatchString(line): - m := orderedListRe.FindStringSubmatch(line) - text := strings.TrimSpace(m[3]) - b.WriteString(" " + styleMarkdownBullet.Render(m[2]+". ") + renderInline(text)) - default: - b.WriteString(renderInline(line)) - } - - if i < len(lines)-1 { - b.WriteByte('\n') - } - } - - return strings.TrimRight(b.String(), "\n") -} - -func ruleLine(width int) string { - if width <= 0 || width > 80 { - width = 40 - } - if width < 8 { - width = 8 - } - return strings.Repeat("-", width) -} - -func isHeading(line string) bool { - if !strings.HasPrefix(line, "#") { - return false - } - n := 0 - for n < len(line) && line[n] == '#' { - n++ - } - return n > 0 && n <= 6 && n < len(line) && unicode.IsSpace(rune(line[n])) -} - -func splitHeading(line string) (int, string) { - n := 0 - for n < len(line) && line[n] == '#' { - n++ - } - return n, strings.TrimSpace(line[n:]) -} - -func isHorizontalRule(line string) bool { - if len(line) < 3 { - return false - } - for _, r := range line { - if r != '-' && r != '*' && r != '_' { - return false - } - } - return true -} - -func isUnorderedList(line string) bool { - if len(line) < 2 { - return false - } - switch line[0] { - case '-', '*', '+': - return unicode.IsSpace(rune(line[1])) - default: - return false - } -} - -func isTableRow(line string) bool { - return strings.HasPrefix(line, "|") && strings.HasSuffix(line, "|") && strings.Count(line, "|") >= 2 -} - -func isTableDelimiter(line string) bool { - if !isTableRow(line) { - return false - } - for _, cell := range strings.Split(strings.Trim(line, "|"), "|") { - cell = strings.TrimSpace(cell) - if cell == "" { - return false - } - cell = strings.Trim(cell, ":") - if len(cell) < 3 { - return false - } - for _, r := range cell { - if r != '-' { - return false - } - } - } - return true -} - -func renderTableRow(line string) string { - parts := strings.Split(strings.Trim(line, "|"), "|") - for i := range parts { - parts[i] = renderInline(strings.TrimSpace(parts[i])) - } - return strings.Join(parts, styleHelp.Render(" | ")) -} - -func renderInline(s string) string { - s = renderLinks(s) - s = renderDelimited(s, "`", func(v string) string { - return styleMarkdownInlineCode.Render(v) - }) - s = renderDelimited(s, "**", func(v string) string { - return styleMarkdownBold.Render(v) - }) - s = renderDelimited(s, "__", func(v string) string { - return styleMarkdownBold.Render(v) - }) - return s -} - -func renderLinks(s string) string { - return linkRe.ReplaceAllStringFunc(s, func(match string) string { - parts := linkRe.FindStringSubmatch(match) - if len(parts) != 3 { - return match - } - label := strings.TrimSpace(parts[1]) - url := strings.TrimSpace(parts[2]) - if label == "" || url == "" { - return match - } - return styleMarkdownLink.Render(label) + styleHelp.Render(" ("+url+")") - }) -} - -func renderDelimited(s, delim string, render func(string) string) string { - if delim == "" { - return s - } - var b strings.Builder - for { - start := strings.Index(s, delim) - if start < 0 { - b.WriteString(s) - break - } - end := strings.Index(s[start+len(delim):], delim) - if end < 0 { - b.WriteString(s) - break - } - end += start + len(delim) - inner := s[start+len(delim) : end] - b.WriteString(s[:start]) - if strings.TrimSpace(inner) == "" { - b.WriteString(delim + inner + delim) - } else { - b.WriteString(render(inner)) - } - s = s[end+len(delim):] - } - return b.String() -} - -func markdownLineCount(s string) int { - if s == "" { - return 0 - } - return strings.Count(s, "\n") + 1 -} diff --git a/internal/tui/markdown_test.go b/internal/tui/markdown_test.go deleted file mode 100644 index e4c5227..0000000 --- a/internal/tui/markdown_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package tui - -import ( - "strings" - "testing" -) - -func TestRenderMarkdownBasicStructure(t *testing.T) { - got := renderMarkdown(`# Plan - -This is **important** and ` + "`stable`" + `. - -- First -- Second - -> Confirm before canon. - -` + "```go" + ` -fmt.Println("ok") -` + "```" + ` -`) - - for _, want := range []string{ - "Plan", - "important", - "stable", - "- First", - "- Second", - "> Confirm before canon.", - "fmt.Println", - } { - if !strings.Contains(got, want) { - t.Fatalf("rendered markdown missing %q:\n%s", want, got) - } - } - for _, raw := range []string{"# Plan", "**important**", "`stable`", "```"} { - if strings.Contains(got, raw) { - t.Fatalf("rendered markdown leaked raw marker %q:\n%s", raw, got) - } - } -} - -func TestStreamingMarkdownRendersIntoTranscript(t *testing.T) { - m := newModel(modelInit{}) - m.appendStreamingText("# Title\n") - m.appendStreamingText("\n- One") - m.flushStreamingText() - - got := m.transcript.String() - if !strings.Contains(got, "Title") || !strings.Contains(got, "- One") { - t.Fatalf("streamed transcript missing rendered markdown:\n%s", got) - } - if strings.Contains(got, "# Title") { - t.Fatalf("streamed transcript leaked raw heading:\n%s", got) - } -} diff --git a/internal/tui/messages.go b/internal/tui/messages.go deleted file mode 100644 index ca2c28c..0000000 --- a/internal/tui/messages.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package tui is openmelon's bubbletea-based interactive surface. -// -// Architecture: the runtime runs in a goroutine and emits Tracer -// callbacks. tracer.go converts each callback into a tea.Msg pushed -// into the Program via Send(). model.go is a pure Bubbletea Model that -// reacts to those messages — no synchronous calls into the runtime. -// -// Messages are intentionally small (each carries one event), so the -// Update path can pattern-match cleanly. -package tui - -import ( - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -// turnStartedMsg fires when the runtime begins a new model turn. The -// TUI uses it to start the spinner. -type turnStartedMsg struct{ Turn int } - -// textDeltaMsg carries one streamed text chunk from the model. -type textDeltaMsg struct{ Delta string } - -// queuedInputAppliedMsg reports that runtime drained queued user input -// and will include it in the next model request. -type queuedInputAppliedMsg struct{ Count int } - -// toolCallMsg announces a tool the model is about to call. -type toolCallMsg struct{ Call llm.ToolCall } - -// toolResultMsg carries the rendered result of a tool call. Err is -// non-nil only when the dispatch itself failed (not when the model -// reported an error inside the JSON body — those are normal results). -type toolResultMsg struct { - Call llm.ToolCall - Content string - Err error -} - -// turnEndedMsg fires when the model's turn is done. -type turnEndedMsg struct { - Turn int - Finish llm.FinishReason - Usage llm.Usage -} - -// runDoneMsg signals the entire Run() call returned. The TUI re-arms -// the input box and persists the new history. -type runDoneMsg struct { - Result *runtime.RunResult - Err error -} - -// approvalRequestMsg is what tools.Env.Approve sends when a tool -// (currently just bash) needs the user to confirm. The TUI freezes -// in a modal until the user picks one of Yes / Yes-always-for-binary -// / No, then sends the resulting ApprovalDecision down Reply. -type approvalRequestMsg struct { - Tool string - Command string - Description string - Binary string - Reply chan tools.ApprovalDecision -} diff --git a/internal/tui/model.go b/internal/tui/model.go deleted file mode 100644 index 189bda4..0000000 --- a/internal/tui/model.go +++ /dev/null @@ -1,2507 +0,0 @@ -package tui - -// model.go — the Bubbletea Model. -// -// State machine: -// -// stateIdle — waiting for user input -// stateRunning — runtime executing; spinner active; input is read-only -// stateQuitArmed — Ctrl-C pressed once; second press exits -// -// Layout, top to bottom: -// -// 1. viewport (scrollable transcript) -// 2. one-line spinner row (only when running) -// 3. textarea (bordered, multi-line input) -// 4. status line (project · model · key hints) - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strings" - "time" - - osc52 "github.com/aymanbagabas/go-osc52/v2" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/eight-acres-lab/openmelon/internal/continuity" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/onboard" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/session" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -type runState int - -const ( - stateIdle runState = iota - stateRunning - stateQuitArmed - stateModelSelect // /model — pick LLM from preset list - stateModelCustom // /model → "Custom..." → typing a model id - stateImageModelSelect // /model-image — pick image model - stateImageModelCustom // /model-image → "Custom..." → typing - stateApprovalPending // bash tool waiting on user confirmation - stateSettings // /settings — bash permission picker - stateSkillSelect // /skill — pick a skillplus package -) - -type transcriptBlockKind int - -const ( - transcriptLine transcriptBlockKind = iota - transcriptMarkdown -) - -type transcriptBlock struct { - kind transcriptBlockKind - text string -} - -// Model is the Bubbletea Model. Constructed by Run() and never used -// outside the program loop. -type Model struct { - // Wired by Run() before tea.NewProgram. - workdir string - project *projectx.Project - rt *runtime.Runtime - systemPrompt string - session *session.Session - persistedUpTo int - - // Runner — the function the worker goroutine calls. Indirected so - // tests can substitute a fake. - runner func(ctx context.Context, in runtime.RunInput) (*runtime.RunResult, error) - - // Components. - textarea textarea.Model - viewport viewport.Model - spinner spinner.Model - markdown MarkdownRenderer - - // State. - state runState - keys keyMap - width, height int - transcriptBlocks []transcriptBlock - transcript strings.Builder // rendered transcript cache fed into viewport - streamingText bool // true if currently mid-stream of an assistant markdown reply - streamingRaw strings.Builder // raw markdown accumulated for the current assistant stream - anchoredBottom bool // true when new transcript content should auto-follow - inputHistory []string - historyCursor int - inputDraft string - queuedInputCh chan string - appliedInputCh chan int - pendingInputs int - history []llm.Message - currentTurn int - cancelTurn context.CancelFunc - quitArmedExpiry time.Time - - // Per-Run telemetry. activityText is what the spinner row shows - // ("Asking gpt-5.5…", "Calling search…", "Streaming response…"). - // runStartedAt anchors the elapsed timer. promptTokens / - // completionTokens accumulate across all turns of one Run so users - // can see the cost building up. - activityText string - runStartedAt time.Time - promptTokens int - completionTokens int - - // Status info displayed in the bottom bar. - llmTag string // e.g. "openrouter:openai/gpt-5" - imageTag string // e.g. "openrouter:google/gemini-2.5-flash-image" - - // Slash-command palette state. Visible when the textarea value - // starts with "/" — the palette filters known commands as the user - // types more, Up/Down navigates filtered rows, Tab autocompletes - // the textarea to the selected command. Enter submits as usual. - paletteVisible bool - paletteCursor int - - // Model-selector state, used when state is stateModelSelect / - // stateImageModelSelect. The cursor points into the (presets + - // "Custom...") row list. - provider string // "openrouter" / "openai" / "anthropic" - imageProvider string // possibly different (e.g. anthropic LLM + openai image) - llmModel string // current - imageModel string // current; "" means image disabled - selectorCursor int - customModelInput textinput.Model - rebuildLLM func(model string) (tag string, err error) - rebuildImageModel func(provider, model string) (tag string, err error) - - // Active approval modal — set when an approvalRequestMsg arrives, - // cleared after the user answers. approvalCursor: 0=Yes, - // 1=Yes-always, 2=No. - approvalReq *approvalRequestMsg - approvalCursor int - approvalScroll int - - // Settings panel state. - settingsCursor int - bashMode projectx.BashPermissionMode - reasoningEffort string - saveSettings func(s projectx.Settings) error - - // resumedFrom is the prior session id when this run was started - // via `openmelon resume`. Shown in the banner; used in the exit - // hint footer. - resumedFrom string - - // Active skillplus selection. activeSkill is the slug picked via - // /skill; the next user submit prepends a hint instructing the - // model to compile_skill it. Cleared on /skill clear. - activeSkill string - skillList []skillplus.SkillInfo - skillCursor int - skillLoadErr string // set when ListSkills failed; rendered in picker -} - -// slashCommand is one row in the slash palette. -type slashCommand struct { - name string // including the leading "/" - help string -} - -// slashCommands lists every command openmelon recognizes inside the -// REPL. Order shown in the palette is the order here. -var slashCommands = []slashCommand{ - {"/help", "show this list of commands"}, - {"/skill", "pick a skillplus package for the next message"}, - {"/model", "switch the LLM model for this session"}, - {"/model-image", "switch the image-generation model"}, - {"/settings", "open the settings panel (bash permissions, etc.)"}, - {"/copy", "copy the full session transcript to clipboard"}, - {"/clear", "forget the conversation history"}, - {"/history", "print the message log so far"}, - {"/save", "write the conversation to a file (jsonl)"}, - {"/session", "show the session directory"}, - {"/events", "show recent session lifecycle events"}, - {"/space", "show a creative space summary"}, - {"/compact", "print a space compaction draft"}, - {"/exit", "exit"}, -} - -// modelInit is the data Run() passes to construct the initial Model. -type modelInit struct { - Workdir string - Project *projectx.Project - Runtime *runtime.Runtime - SystemPrompt string - Session *session.Session - LLMTag string - ImageTag string - Runner func(ctx context.Context, in runtime.RunInput) (*runtime.RunResult, error) - - // Provider info used to populate the /model and /model-image - // selectors. Provider is required; ImageProvider may be "" when - // the user has no image model configured. LLMModel / ImageModel - // are the currently active ids (used to render the ✓ marker). - Provider string - ImageProvider string - LLMModel string - ImageModel string - - // BashMode is the current project setting (strict / auto / - // trusted), surfaced in the /settings panel. - BashMode projectx.BashPermissionMode - ReasoningEffort string - - // SaveSettings persists a Settings change made via the /settings - // panel back to project.json AND triggers any side-effects (e.g. - // rebuilding the tools env so the bash mode change takes effect - // immediately without restart). - SaveSettings func(s projectx.Settings) error - - // InitialHistory pre-populates the conversation when resuming. - InitialHistory []llm.Message - - // ResumedFrom is the prior session id (used for the banner). - ResumedFrom string - - // RebuildLLM is called when the user picks a new LLM model in the - // /model selector. It must construct a fresh llm.Client + Tool- - // Caller against the same provider, swap it into Runtime.LLM, and - // return the new ":" tag for the status bar. - // The implementation also persists the new model into the - // project.json defaults. - RebuildLLM func(model string) (string, error) - - // RebuildImageModel is called when the user picks a new image - // model. provider may be empty to disable image generation; in - // that case the returned tag is "". The impl rebuilds the tools - // registry with the new ImageGen and persists the choice. - RebuildImageModel func(provider, model string) (string, error) -} - -func newModel(init modelInit) *Model { - ta := textarea.New() - ta.Placeholder = "Ask anything" - ta.Prompt = "› " - ta.CharLimit = 0 - ta.SetHeight(1) - ta.ShowLineNumbers = false - // Strip the bordered chrome bubbles paints by default. Claude Code - // is just a "› " prompt followed by the cursor — no panel around it. - ta.FocusedStyle.Base = lipgloss.NewStyle() - ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.FocusedStyle.Prompt = stylePromptArrow - ta.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(colorMuted) - ta.BlurredStyle.Base = lipgloss.NewStyle() - ta.BlurredStyle.CursorLine = lipgloss.NewStyle() - ta.BlurredStyle.Prompt = stylePromptArrow - ta.BlurredStyle.Placeholder = lipgloss.NewStyle().Foreground(colorMuted) - ta.Focus() - - vp := viewport.New(80, 20) - vp.SetContent("") - - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = styleSpinner - - return &Model{ - workdir: init.Workdir, - project: init.Project, - rt: init.Runtime, - provider: init.Provider, - imageProvider: init.ImageProvider, - llmModel: init.LLMModel, - imageModel: init.ImageModel, - rebuildLLM: init.RebuildLLM, - rebuildImageModel: init.RebuildImageModel, - bashMode: init.BashMode, - reasoningEffort: init.ReasoningEffort, - saveSettings: init.SaveSettings, - history: append([]llm.Message(nil), init.InitialHistory...), - resumedFrom: init.ResumedFrom, - systemPrompt: init.SystemPrompt, - session: init.Session, - runner: init.Runner, - llmTag: init.LLMTag, - imageTag: init.ImageTag, - textarea: ta, - viewport: vp, - spinner: sp, - markdown: newMarkdownRenderer(), - state: stateIdle, - keys: defaultKeys(), - anchoredBottom: true, - historyCursor: -1, - queuedInputCh: make(chan string, 32), - appliedInputCh: make(chan int, 8), - } -} - -// Init starts the spinner ticker and shows the welcome banner. -func (m *Model) Init() tea.Cmd { - // The persistent identity row is now the top header (see View), - // so the transcript only needs the per-launch hints. - if m.session != nil { - m.appendLine(styleHelp.Render("session " + shortSession(m.session.Dir))) - } - if m.resumedFrom != "" { - m.appendLine(styleHelp.Render("resumed from " + m.resumedFrom)) - } - m.appendLine(styleHelp.Render( - "Type a request and press ↵. /help for commands. Esc cancels a turn; Ctrl+C twice to quit.", - )) - m.appendLine("") - // Render any resumed history into the transcript. - if len(m.history) > 0 { - m.appendLine(styleHelp.Render(fmt.Sprintf("─── prior conversation (%d messages) ───", len(m.history)))) - m.appendLine("") - m.renderHistoricMessages(m.history) - m.appendLine(styleHelp.Render("─── continue below ───")) - m.appendLine("") - // History is on disk via a different session — we don't - // re-persist it. persistedUpTo starts at len(history) so the - // new session only writes truly-new messages. - m.persistedUpTo = len(m.history) - } - return tea.Batch(textarea.Blink, m.spinner.Tick) -} - -func (m *Model) renderHistoricMessages(messages []llm.Message) { - for i, msg := range messages { - before := len(m.transcriptBlocks) - m.renderHistoricMessage(msg) - after := len(m.transcriptBlocks) - if after == before { - continue - } - if historicMessageNeedsSpacer(msg, nextMessage(messages, i)) { - m.appendLine("") - } - } -} - -// renderHistoricMessage prints one prior message into the transcript -// in the same format the live session uses, so a resumed conversation -// reads continuously. -func (m *Model) renderHistoricMessage(msg llm.Message) { - switch msg.Role { - case llm.RoleSystem: - // Skip — system prompt is internal noise for the user. - case llm.RoleUser: - m.appendUserMessage(msg.Content) - case llm.RoleAssistant: - if strings.TrimSpace(msg.Content) != "" { - m.appendMarkdown(msg.Content) - } - for _, tc := range msg.ToolCalls { - m.appendLine(renderToolCall(tc)) - } - case llm.RoleTool: - // We don't have the original ToolCall here, just the content. - m.appendLine(renderToolResult(llm.ToolCall{}, msg.Content, nil)) - } -} - -func (m *Model) appendUserMessage(text string) { - lines := strings.Split(text, "\n") - if len(lines) == 0 { - m.appendLine(styleUserPrompt.Render("> ")) - return - } - m.appendLine(styleUserPrompt.Render("> ") + lines[0]) - for _, line := range lines[1:] { - m.appendLine(styleUserPrompt.Render(" ") + line) - } -} - -func historicMessageNeedsSpacer(msg llm.Message, next *llm.Message) bool { - if next == nil { - return true - } - switch msg.Role { - case llm.RoleSystem: - return false - case llm.RoleUser: - return true - case llm.RoleTool: - return next.Role != llm.RoleTool - case llm.RoleAssistant: - return strings.TrimSpace(msg.Content) != "" || len(msg.ToolCalls) == 0 - default: - return true - } -} - -func nextMessage(messages []llm.Message, i int) *llm.Message { - for j := i + 1; j < len(messages); j++ { - if messages[j].Role != llm.RoleSystem { - return &messages[j] - } - } - return nil -} - -// Update is the bubbletea event reducer. -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.resize(msg.Width, msg.Height) - return m, nil - - case tea.MouseMsg: - if m.state == stateApprovalPending { - switch msg.Type { - case tea.MouseWheelUp: - m.scrollApproval(-3) - case tea.MouseWheelDown: - m.scrollApproval(3) - } - return m, nil - } - // bubbles/viewport handles wheel events natively; we just need - // to forward the message when mouse reporting is enabled. - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - m.updateScrollAnchor() - return m, cmd - - case tea.KeyMsg: - // Approval modal owns all input until the user answers. - if m.state == stateApprovalPending { - m.updateApproval(msg) - return m, tea.Batch(cmds...) - } - if m.state == stateSettings { - m.updateSettings(msg) - return m, tea.Batch(cmds...) - } - if m.state == stateSkillSelect { - m.updateSkillSelect(msg) - return m, tea.Batch(cmds...) - } - // Selector states own all key input until they exit. - if m.inSelector() { - switch m.state { - case stateModelSelect, stateImageModelSelect: - if cmd := m.updateSelector(msg); cmd != nil { - cmds = append(cmds, cmd) - } - case stateModelCustom, stateImageModelCustom: - if cmd := m.updateCustomInput(msg); cmd != nil { - cmds = append(cmds, cmd) - } - } - return m, tea.Batch(cmds...) - } - // Arm/disarm quit on Ctrl+C. - if key.Matches(msg, m.keys.Quit) { - if m.state == stateRunning { - // First Ctrl+C while running → cancel the turn (Esc - // also does this; Ctrl+C is the "I really mean it" path). - m.cancelCurrentTurn("interrupted") - return m, nil - } - if m.state == stateQuitArmed && time.Now().Before(m.quitArmedExpiry) { - return m, tea.Quit - } - m.state = stateQuitArmed - m.quitArmedExpiry = time.Now().Add(2 * time.Second) - m.appendLine(styleWarn.Render("Press Ctrl+C again within 2s to quit.")) - return m, nil - } - if m.state == stateQuitArmed { - // Any other key disarms. - m.state = stateIdle - } - - if key.Matches(msg, m.keys.Cancel) { - if m.state == stateRunning { - m.cancelCurrentTurn("interrupted") - return m, nil - } - // In idle, Esc dismisses the palette if visible, otherwise - // clears the input. - if m.paletteVisible { - m.paletteVisible = false - return m, nil - } - m.textarea.Reset() - m.recomputeLayout() - return m, nil - } - - if key.Matches(msg, m.keys.CopyTranscript) { - m.copyTranscriptToClipboard() - return m, nil - } - - if key.Matches(msg, m.keys.ScrollU) { - m.viewport.HalfPageUp() - m.updateScrollAnchor() - return m, nil - } - if key.Matches(msg, m.keys.ScrollD) { - m.viewport.HalfPageDown() - m.updateScrollAnchor() - return m, nil - } - - // Slash-palette navigation. Only intercepts when palette is - // open — otherwise these keys fall through to the textarea - // (Up/Down would normally move the cursor in multi-line input). - if m.state == stateIdle && m.paletteVisible { - switch msg.String() { - case "up": - if m.paletteCursor > 0 { - m.paletteCursor-- - } - return m, nil - case "down": - if filt := m.paletteFiltered(); m.paletteCursor < len(filt)-1 { - m.paletteCursor++ - } - return m, nil - case "tab": - // Tab autocompletes to the selected command + a trailing - // space so the user can immediately type args. - filt := m.paletteFiltered() - if len(filt) > 0 { - m.textarea.SetValue(filt[m.paletteCursor].name + " ") - m.textarea.SetCursor(len(m.textarea.Value())) - m.paletteVisible = false - m.recomputeLayout() - } - return m, nil - case "enter": - // Enter executes the highlighted command directly. - // (No args path — for that, use Tab to autocomplete, - // type args, then Enter.) - filt := m.paletteFiltered() - if len(filt) == 0 { - return m, nil // nothing to select - } - cmd := filt[m.paletteCursor].name - m.paletteVisible = false - m.textarea.Reset() - m.recomputeLayout() - return m, m.submit(cmd) - } - } - - if (m.state == stateIdle || m.state == stateRunning) && m.handleInputHistoryKey(msg) { - return m, nil - } - - if (m.state == stateIdle || m.state == stateRunning) && key.Matches(msg, m.keys.Newline) { - m.insertInputNewline() - return m, nil - } - - if (m.state == stateIdle || m.state == stateRunning) && key.Matches(msg, m.keys.Submit) { - text := strings.TrimSpace(m.textarea.Value()) - if text != "" { - if m.state == stateRunning { - m.queueInput(text) - return m, nil - } - m.paletteVisible = false - return m, m.submit(text) - } - return m, nil - } - - // Otherwise, route into textarea (handles shift+enter for - // newlines automatically). - if m.state == stateIdle || m.state == stateRunning { - var cmd tea.Cmd - m.textarea, cmd = m.textarea.Update(msg) - m.resetInputHistoryBrowse() - if m.state == stateIdle { - m.refreshPalette() - } else { - m.paletteVisible = false - } - m.recomputeLayout() - cmds = append(cmds, cmd) - } - - case spinner.TickMsg: - m.consumeAppliedInputAcks() - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - - case elapsedTickMsg: - // Re-render once a second so the elapsed timer in the spinner - // row updates. Only schedule the next tick while running. - if m.state == stateRunning { - cmds = append(cmds, scheduleElapsedTick()) - } - - case turnStartedMsg: - m.currentTurn = msg.Turn - m.activityText = "Thinking with " + m.llmModel - // nothing to render — spinner row shows the activity - - case textDeltaMsg: - m.activityText = "Streaming response" - m.appendStreamingText(msg.Delta) - - case queuedInputAppliedMsg: - if msg.Count > m.pendingInputs { - m.pendingInputs = 0 - } else { - m.pendingInputs -= msg.Count - } - - case toolCallMsg: - m.activityText = "Calling " + msg.Call.Name - m.flushStreamingText() - m.appendLine(renderToolCall(msg.Call)) - - case toolResultMsg: - m.activityText = "Got " + msg.Call.Name + " result" - m.appendLine(renderToolResult(msg.Call, msg.Content, msg.Err)) - - case turnEndedMsg: - m.flushStreamingText() - m.promptTokens += msg.Usage.PromptTokens - m.completionTokens += msg.Usage.CompletionTokens - // Spacer between model turns inside one Run(). - m.appendLine("") - - case approvalRequestMsg: - // Worker goroutine is blocked on msg.Reply. Stash the request - // and switch to the approval-pending state so the next View() - // renders the modal. - req := msg - m.approvalReq = &req - m.approvalCursor = 0 - m.approvalScroll = 0 - m.state = stateApprovalPending - m.recomputeLayout() - - case runDoneMsg: - m.state = stateIdle - m.cancelTurn = nil - draft := m.textarea.Value() - if msg.Result != nil { - m.history = msg.Result.Messages - if m.session != nil && m.persistedUpTo < len(m.history) { - _ = m.session.AppendMessages(m.history[m.persistedUpTo:]) - m.persistedUpTo = len(m.history) - } - if msg.Result.FinishSummary != "" { - m.appendLine("") - m.appendMarkdown(msg.Result.FinishSummary) - } - for _, p := range msg.Result.FinishArtifacts { - m.appendLine(styleHelp.Render(" artifact: " + p)) - } - } - if msg.Err != nil { - if errIsCanceled(msg.Err) { - m.appendLine(styleWarn.Render("[interrupted]")) - } else { - m.appendLine(styleErr.Render(fmt.Sprintf("error: %v", msg.Err))) - } - } - m.appendLine("") - m.textarea.Focus() - m.consumeAppliedInputAcks() - if next := m.takePendingInputForNextRun(); next != "" { - cmds = append(cmds, m.submit(next)) - if draft != "" { - m.textarea.SetValue(draft) - m.textarea.SetCursor(len(draft)) - } - } - } - - return m, tea.Batch(cmds...) -} - -// View renders the current frame. -// -// Layout, top to bottom: -// 1. viewport (scrollable transcript) -// 2. spinner row (only while running) -// 3. slash-command palette (only when visible) -// 4. textarea — no border, just "› " prompt + cursor -// 5. status line — project + model only, no key hints -func (m *Model) View() string { - var b strings.Builder - // Fixed header — top-left. Project + model identity stays anchored - // here regardless of terminal size or scroll position. Replaces - // the old bottom status bar. - b.WriteString(m.headerLine()) - b.WriteString("\n") - b.WriteString(m.viewport.View()) - b.WriteString("\n") - - if m.paletteVisible { - b.WriteString(m.renderPalette()) - } - - switch m.state { - case stateRunning: - b.WriteString(m.runningStatusRow()) - b.WriteString("\n") - b.WriteString(m.textarea.View()) - b.WriteString("\n") - case stateApprovalPending: - b.WriteString(m.renderApproval()) - case stateSettings: - b.WriteString(m.renderSettings()) - case stateSkillSelect: - b.WriteString(m.renderSkillSelect()) - case stateModelSelect, stateImageModelSelect: - b.WriteString(m.renderSelector()) - case stateModelCustom, stateImageModelCustom: - b.WriteString(m.renderCustomInput()) - default: - b.WriteString(m.textarea.View()) - b.WriteString("\n") - } - return b.String() -} - -// runningStatusRow renders the single-line status shown in place of -// the input while a turn is in flight: -// -// ⠋ Calling search · 0:12 · 1.2k in / 340 out · esc to cancel -func (m *Model) runningStatusRow() string { - parts := []string{ - m.spinner.View() + " " + m.activityText, - formatElapsed(time.Since(m.runStartedAt)), - formatTokens(m.promptTokens, m.completionTokens), - } - if m.pendingInputs > 0 { - parts = append(parts, fmt.Sprintf("%d pending", m.pendingInputs)) - } - parts = append(parts, styleHelp.Render("enter adds context · esc cancels")) - // Filter empty cells. - filtered := make([]string, 0, len(parts)) - for _, p := range parts { - if p != "" { - filtered = append(filtered, p) - } - } - return strings.Join(filtered, styleHelp.Render(" · ")) -} - -// formatElapsed renders a Duration as "0:12" / "1:23". -func formatElapsed(d time.Duration) string { - s := int(d.Seconds()) - return fmt.Sprintf("%d:%02d", s/60, s%60) -} - -// formatTokens renders a "Nk in / Nk out" string when usage has been -// reported. Returns "" when both counters are zero (we hide the field -// rather than show "0 in / 0 out", which is noise pre-first-turn). -func formatTokens(in, out int) string { - if in == 0 && out == 0 { - return "" - } - return fmt.Sprintf("%s in / %s out", shortInt(in), shortInt(out)) -} - -// shortInt formats an integer as "1.2k" / "12.3k" / "423" — terse -// enough to fit on the running status row alongside everything else. -func shortInt(n int) string { - if n < 1000 { - return fmt.Sprintf("%d", n) - } - if n < 100000 { - return fmt.Sprintf("%.1fk", float64(n)/1000) - } - return fmt.Sprintf("%dk", n/1000) -} - -// --- helpers --- - -// resize handles tea.WindowSizeMsg — store the new size, then recompute -// all dependent dimensions. recomputeLayout calls refreshViewport -// internally, which re-pads the transcript at the new viewport height. -func (m *Model) resize(w, h int) { - m.width = w - m.height = h - m.recomputeLayout() -} - -// recomputeLayout sizes the viewport + textarea based on (a) terminal -// size, (b) current textarea content (auto-grow up to maxInputLines), -// (c) whether the spinner row + palette are showing. -// -// Called from resize() and after every keystroke that may have changed -// the textarea height. -func (m *Model) recomputeLayout() { - if m.width == 0 || m.height == 0 { - return - } - // Auto-grow textarea: count explicit newlines and soft-wrapped - // visual rows. bubbles/textarea wraps long lines internally, but - // if height stays at 1 the line appears to overflow horizontally. - const maxInputLines = 10 - taLines := inputVisualLines(m.textarea.Value(), inputTextWidth(m.width)) - if taLines < 1 { - taLines = 1 - } - if taLines > maxInputLines { - taLines = maxInputLines - } - if m.textarea.Height() != taLines { - m.textarea.SetHeight(taLines) - } - m.textarea.SetWidth(m.width) - - paletteRows := 0 - if m.paletteVisible { - // Palette renders one row per filtered command + a header. - paletteRows = len(m.paletteFiltered()) + 1 - if paletteRows > 8 { - paletteRows = 8 - } - } - // State-specific overlays can either sit above the textarea - // (running status) or replace it (modals/selectors). - overlayRows := 0 - replaceInput := false - switch m.state { - case stateRunning: - overlayRows = 1 // single status row - case stateApprovalPending: - replaceInput = true - overlayRows = m.approvalBodyRows() + 9 - case stateSettings: - replaceInput = true - overlayRows = 12 // header + desc + 3 mode rows + footer + spacing - case stateSkillSelect: - replaceInput = true - rows := len(m.skillList) + 1 // skills + "(none)" - if rows < 2 { - rows = 2 - } - if rows > 12 { - rows = 12 - } - overlayRows = rows + 5 // header + desc + rows + footer - case stateModelSelect, stateImageModelSelect: - replaceInput = true - overlayRows = len(m.modelSelectorRows()) + 5 // header+desc+blank+rows+blank+footer - case stateModelCustom, stateImageModelCustom: - replaceInput = true - overlayRows = 6 - } - if replaceInput { - taLines = 0 - } - const headerRows = 1 - const spacingRows = 1 // newline between viewport and the rest - vpHeight := m.height - taLines - overlayRows - paletteRows - headerRows - spacingRows - if vpHeight < 5 { - vpHeight = 5 - } - m.viewport.Width = m.width - m.viewport.Height = vpHeight - // Re-pad transcript so content stays bottom-anchored as the - // viewport's effective height changes (palette opens/closes, - // terminal resizes). - m.refreshViewport() -} - -// refreshPalette toggles the palette based on the current textarea -// value. Visible when the value starts with "/" and the user hasn't -// pressed space yet (slash + word, not "/foo arg" — once they type a -// space, the command is presumed picked). -func (m *Model) refreshPalette() { - val := m.textarea.Value() - if !strings.HasPrefix(val, "/") { - m.paletteVisible = false - m.paletteCursor = 0 - return - } - if strings.Contains(val, " ") { - // User has moved past the command into args — hide palette. - m.paletteVisible = false - return - } - m.paletteVisible = true - // Clamp cursor in case the filtered list shrank. - if max := len(m.paletteFiltered()) - 1; m.paletteCursor > max { - m.paletteCursor = 0 - if max < 0 { - m.paletteCursor = 0 - } - } -} - -// paletteFiltered returns the slash commands whose name starts with the -// current textarea value (case-insensitive prefix match). -func (m *Model) paletteFiltered() []slashCommand { - q := strings.ToLower(strings.TrimSpace(m.textarea.Value())) - if q == "" || q == "/" { - out := make([]slashCommand, len(slashCommands)) - copy(out, slashCommands) - return out - } - var out []slashCommand - for _, c := range slashCommands { - if strings.HasPrefix(c.name, q) { - out = append(out, c) - } - } - return out -} - -func inputTextWidth(totalWidth int) int { - const promptWidth = 2 // "› " - w := totalWidth - promptWidth - if w < 1 { - return 1 - } - return w -} - -func inputVisualLines(value string, width int) int { - if width < 1 { - width = 1 - } - if value == "" { - return 1 - } - lines := strings.Split(value, "\n") - total := 0 - for _, line := range lines { - runes := []rune(line) - n := len(runes) - rows := (n / width) + 1 - if n > 0 && n%width == 0 { - rows = n / width - } - if rows < 1 { - rows = 1 - } - total += rows - } - return total -} - -// renderPalette renders the floating list above the textarea. -func (m *Model) renderPalette() string { - filt := m.paletteFiltered() - if len(filt) == 0 { - return stylePaletteHelp.Render(" (no matching commands)") + "\n" - } - var b strings.Builder - for i, c := range filt { - if i >= 8 { - break - } - marker := " " - name := stylePaletteName.Render(c.name) - if i == m.paletteCursor { - marker = stylePaletteActive.Render("› ") - name = stylePaletteActive.Render(c.name) - } - help := stylePaletteHelp.Render(" " + c.help) - b.WriteString(marker + name + help + "\n") - } - return b.String() -} - -// appendLine writes one logical line into the transcript and scrolls -// the viewport to the bottom. Wrapping is deferred until render time so -// a terminal resize can reflow all prior transcript content. -func (m *Model) appendLine(line string) { - m.transcriptBlocks = append(m.transcriptBlocks, transcriptBlock{kind: transcriptLine, text: line}) - m.refreshViewportContent(true) -} - -func (m *Model) appendMarkdown(markdown string) { - if strings.TrimSpace(markdown) == "" { - return - } - m.transcriptBlocks = append(m.transcriptBlocks, transcriptBlock{kind: transcriptMarkdown, text: markdown}) - m.refreshViewportContent(true) -} - -func (m *Model) renderMarkdown(markdown string) string { - width := m.transcriptWidth() - if m.markdown == nil { - return renderMarkdownWithWidth(markdown, width) - } - return m.markdown.Render(markdown, width) -} - -func (m *Model) transcriptWidth() int { - width := m.viewport.Width - if width <= 0 { - width = m.width - } - if width <= 0 { - width = 80 - } - return width -} - -func wrapTranscriptLine(line string, width int) []string { - if line == "" { - return []string{""} - } - if width < 8 { - width = 8 - } - wrapped := ansi.Wrap(line, width, "/._=&?:,") - if wrapped == "" { - return []string{""} - } - lines := strings.Split(wrapped, "\n") - indent := leadingPlainIndent(line) - if indent != "" { - continuationIndent := indent + " " - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) != "" { - lines[i] = continuationIndent + strings.TrimLeft(lines[i], " ") - } - } - } - return lines -} - -func leadingPlainIndent(s string) string { - i := 0 - for i < len(s) { - switch s[i] { - case ' ', '\t': - i++ - default: - return s[:i] - } - } - return s -} - -// refreshViewport feeds the transcript into the viewport, padding with -// leading blank lines when content is shorter than the viewport so the -// content sits at the bottom (right above the palette/input) instead of -// at the top with a sea of empty space below. -// -// Once content exceeds viewport height, padding becomes 0 and normal -// scroll-from-bottom behavior takes over. -func (m *Model) refreshViewport() { - m.refreshViewportContent(false) -} - -func (m *Model) refreshViewportContent(contentChanged bool) { - wasBottom := m.viewport.AtBottom() - if m.viewport.TotalLineCount() == 0 { - wasBottom = true - } - content := m.renderTranscript() - if m.viewport.Height > 0 { - // Count rendered lines (transcript ends with \n, so subtract 1). - nl := strings.Count(content, "\n") - if nl < m.viewport.Height { - pad := strings.Repeat("\n", m.viewport.Height-nl) - content = pad + content - } - } - m.viewport.SetContent(content) - switch { - case contentChanged && (m.anchoredBottom || wasBottom): - m.viewport.GotoBottom() - m.anchoredBottom = true - case !contentChanged && m.anchoredBottom: - m.viewport.GotoBottom() - default: - m.viewport.SetYOffset(m.viewport.YOffset) - m.updateScrollAnchor() - } -} - -func (m *Model) updateScrollAnchor() { - m.anchoredBottom = m.viewport.AtBottom() -} - -func (m *Model) renderTranscript() string { - var b strings.Builder - for _, block := range m.transcriptBlocks { - m.renderTranscriptBlock(&b, block, true) - } - if m.streamingText { - m.renderTranscriptBlock(&b, transcriptBlock{kind: transcriptMarkdown, text: m.streamingRaw.String()}, true) - } - m.transcript.Reset() - m.transcript.WriteString(b.String()) - return b.String() -} - -func (m *Model) renderPlainTranscript() string { - var b strings.Builder - for _, block := range m.transcriptBlocks { - m.renderTranscriptBlock(&b, block, false) - } - if m.streamingText { - m.renderTranscriptBlock(&b, transcriptBlock{kind: transcriptMarkdown, text: m.streamingRaw.String()}, false) - } - return strings.TrimRight(b.String(), "\n") -} - -func (m *Model) copyTranscriptToClipboard() { - text := m.renderPlainTranscript() - if strings.TrimSpace(text) == "" { - m.appendLine(styleWarn.Render("nothing to copy")) - return - } - seq := osc52.New(text) - if os.Getenv("TMUX") != "" { - seq = seq.Tmux() - } - if _, err := seq.WriteTo(os.Stderr); err != nil { - m.appendLine(styleErr.Render("copy failed: " + err.Error())) - return - } - m.appendLine(styleHelp.Render(fmt.Sprintf("copied transcript (%d chars)", len([]rune(text))))) -} - -func (m *Model) renderTranscriptBlock(b *strings.Builder, block transcriptBlock, styled bool) { - var rendered string - switch block.kind { - case transcriptMarkdown: - if styled { - rendered = m.renderMarkdown(block.text) - } else { - rendered = renderMarkdownPlain(block.text) - } - default: - rendered = block.text - } - if !styled { - rendered = ansi.Strip(rendered) - } - for _, line := range strings.Split(rendered, "\n") { - for _, wrapped := range wrapTranscriptLine(line, m.transcriptWidth()) { - b.WriteString(wrapped) - b.WriteString("\n") - } - } -} - -// appendStreamingText accumulates a streaming assistant reply. Replaces -// the trailing line in-place rather than appending (so streaming reads -// as one growing line, not many short lines). -func (m *Model) appendStreamingText(delta string) { - if !m.streamingText { - m.streamingText = true - m.streamingRaw.Reset() - } - m.streamingRaw.WriteString(delta) - m.refreshViewportContent(true) -} - -// flushStreamingText finalizes any in-progress streaming text by -// terminating it with a newline. -func (m *Model) flushStreamingText() { - if !m.streamingText { - return - } - if strings.TrimSpace(m.streamingRaw.String()) != "" { - m.transcriptBlocks = append(m.transcriptBlocks, transcriptBlock{kind: transcriptMarkdown, text: m.streamingRaw.String()}) - } - m.streamingText = false - m.streamingRaw.Reset() - m.refreshViewportContent(true) -} - -// submit kicks off a runtime.Run in a worker goroutine. Returns the -// tea.Cmd that the worker will eventually use to send runDoneMsg back. -func (m *Model) submit(text string) tea.Cmd { - // Slash command? Handle inline. - if strings.HasPrefix(text, "/") { - m.resetInputHistoryBrowse() - return m.handleSlash(text) - } - - m.recordInputHistory(text) - if m.session != nil { - _ = m.session.AppendPrompt("user", text) - } - m.appendLine(styleUserPrompt.Render("> ") + text) - m.appendLine("") - m.textarea.Reset() - m.textarea.Blur() - m.state = stateRunning - m.runStartedAt = time.Now() - m.activityText = "Sending to " + m.llmModel - m.promptTokens = 0 - m.completionTokens = 0 - - // Active skill: prepend a hint so the model invokes compile_skill - // for that package before responding. We consume the selection - // here — one /skill pick → one applied message; clear it after - // so the next turn isn't surprise-bound to the same skill. - userInput := text - if m.activeSkill != "" { - // Be explicit about the slug format — earlier sessions saw the - // model emit `skillplus:` (an old spec format) and the - // compile failed. Pass the bare slug. - userInput = fmt.Sprintf( - "Apply the skill %q to this request: first call compile_skill with skill=%q (BARE slug, no 'skillplus:' prefix) to fetch the package's prompt + output schema, then proceed.\n\n%s", - m.activeSkill, m.activeSkill, text, - ) - m.activeSkill = "" - } - in := runtime.RunInput{UserInput: userInput} - if len(m.history) == 0 { - in.SystemPrompt = m.systemPrompt - } else { - in.History = m.history - } - - ctx, cancel := context.WithCancel(context.Background()) - m.cancelTurn = cancel - - runCmd := func() tea.Msg { - prevDrain := m.rt.DrainUserInput - m.rt.DrainUserInput = m.drainQueuedInput - defer func() { m.rt.DrainUserInput = prevDrain }() - res, err := m.runner(ctx, in) - return runDoneMsg{Result: res, Err: err} - } - return tea.Batch(runCmd, scheduleElapsedTick()) -} - -func (m *Model) queueInput(text string) { - text = strings.TrimSpace(text) - if text == "" { - return - } - m.recordInputHistory(text) - if m.session != nil { - _ = m.session.AppendPrompt("pending", text) - } - select { - case m.queuedInputCh <- text: - m.pendingInputs++ - m.appendLine(styleHelp.Render("> " + text + " (pending context)")) - default: - m.appendLine(styleWarn.Render("input queue is full; wait for the next model call")) - } - m.textarea.Reset() - m.textarea.Focus() - m.recomputeLayout() -} - -func (m *Model) drainQueuedInput() []string { - var out []string - for { - select { - case text := <-m.queuedInputCh: - if strings.TrimSpace(text) != "" { - out = append(out, text) - } - default: - if len(out) > 0 { - select { - case m.appliedInputCh <- len(out): - default: - } - } - return out - } - } -} - -func (m *Model) takePendingInputForNextRun() string { - var pending []string - for { - select { - case text := <-m.queuedInputCh: - text = strings.TrimSpace(text) - if text != "" { - pending = append(pending, text) - } - default: - if len(pending) == 0 { - return "" - } - if m.pendingInputs < len(pending) { - m.pendingInputs = 0 - } else { - m.pendingInputs -= len(pending) - } - return strings.Join(pending, "\n\n") - } - } -} - -func (m *Model) consumeAppliedInputAcks() { - for { - select { - case count := <-m.appliedInputCh: - if count <= 0 { - continue - } - if m.pendingInputs < count { - m.pendingInputs = 0 - } else { - m.pendingInputs -= count - } - default: - return - } - } -} - -func (m *Model) handleInputHistoryKey(msg tea.KeyMsg) bool { - switch msg.Type { - case tea.KeyUp: - return m.recallInputHistory(-1) - case tea.KeyDown: - return m.recallInputHistory(1) - default: - return false - } -} - -func (m *Model) recallInputHistory(direction int) bool { - if len(m.inputHistory) == 0 { - return false - } - if m.historyCursor == -1 && !inputHistoryCanStart(m.textarea.Value()) { - return false - } - - switch { - case direction < 0: - if m.historyCursor == -1 { - m.inputDraft = m.textarea.Value() - m.historyCursor = len(m.inputHistory) - 1 - } else if m.historyCursor > 0 { - m.historyCursor-- - } - case direction > 0: - if m.historyCursor == -1 { - return false - } - if m.historyCursor < len(m.inputHistory)-1 { - m.historyCursor++ - } else { - m.textarea.SetValue(m.inputDraft) - m.textarea.SetCursor(len(m.inputDraft)) - m.resetInputHistoryBrowse() - m.refreshPalette() - m.recomputeLayout() - return true - } - default: - return false - } - - if m.historyCursor < 0 || m.historyCursor >= len(m.inputHistory) { - return false - } - value := m.inputHistory[m.historyCursor] - m.textarea.SetValue(value) - m.textarea.SetCursor(len(value)) - m.refreshPalette() - m.recomputeLayout() - return true -} - -func inputHistoryCanStart(value string) bool { - return !strings.Contains(value, "\n") -} - -func (m *Model) recordInputHistory(text string) { - text = strings.TrimSpace(text) - if text == "" { - return - } - if len(m.inputHistory) == 0 || m.inputHistory[len(m.inputHistory)-1] != text { - m.inputHistory = append(m.inputHistory, text) - } - m.resetInputHistoryBrowse() -} - -func (m *Model) resetInputHistoryBrowse() { - m.historyCursor = -1 - m.inputDraft = "" -} - -// handleSlash processes a / command line (slash already included). -// Returns nil tea.Cmd or tea.Quit for /exit. -func (m *Model) handleSlash(line string) tea.Cmd { - parts := strings.Fields(line) - cmd := parts[0] - m.appendLine(styleHelp.Render("> " + line)) - m.textarea.Reset() - switch cmd { - case "/exit", "/quit", "/q": - return tea.Quit - case "/copy": - m.copyTranscriptToClipboard() - return nil - case "/help", "/?": - m.appendLine(styleHelp.Render(" /model switch the LLM model for this session")) - m.appendLine(styleHelp.Render(" /model-image switch the image-generation model")) - m.appendLine(styleHelp.Render(" /clear forget conversation history")) - m.appendLine(styleHelp.Render(" /copy copy the full session transcript to clipboard")) - m.appendLine(styleHelp.Render(" /history print the message log so far")) - m.appendLine(styleHelp.Render(" /save write the conversation to a file (jsonl)")) - m.appendLine(styleHelp.Render(" /session show the session directory")) - m.appendLine(styleHelp.Render(" /events show recent session lifecycle events")) - m.appendLine(styleHelp.Render(" /space show a creative space summary")) - m.appendLine(styleHelp.Render(" /compact print a compaction draft")) - m.appendLine(styleHelp.Render(" /exit | /quit | /q exit")) - case "/model": - m.openModelSelector(false) - return nil - case "/model-image": - m.openModelSelector(true) - return nil - case "/settings", "/config": - m.openSettings() - return nil - case "/skill": - // /skill → open picker - // /skill clear / off → unset active skill - // /skill → set active skill directly - if len(parts) == 1 { - m.openSkillSelector() - return nil - } - arg := parts[1] - if arg == "clear" || arg == "off" || arg == "none" { - if m.activeSkill == "" { - m.appendLine(styleHelp.Render("(no active skill)")) - } else { - m.appendLine(styleHelp.Render("(skill cleared: " + m.activeSkill + ")")) - m.activeSkill = "" - } - return nil - } - m.activeSkill = arg - m.appendLine(styleHelp.Render("(skill: " + arg + ")")) - return nil - case "/clear": - m.history = nil - m.persistedUpTo = 0 - m.transcriptBlocks = nil - m.transcript.Reset() - m.viewport.SetContent("") - m.appendLine(styleHelp.Render("(history cleared)")) - case "/history": - for i, mm := range m.history { - label := string(mm.Role) - if len(mm.ToolCalls) > 0 { - label += " → tool_calls" - } - body := strings.ReplaceAll(mm.Content, "\n", " ") - if len(body) > 200 { - body = body[:200] + "…" - } - m.appendLine(styleHelp.Render(fmt.Sprintf(" [%d] %s: %s", i, label, body))) - } - case "/save": - if len(parts) < 2 { - m.appendLine(styleErr.Render("/save: usage: /save ")) - break - } - f, err := os.Create(parts[1]) - if err != nil { - m.appendLine(styleErr.Render("/save: " + err.Error())) - break - } - enc := json.NewEncoder(f) - var saveErr error - for _, mm := range m.history { - if err := enc.Encode(mm); err != nil { - saveErr = err - break - } - } - if err := f.Close(); saveErr == nil { - saveErr = err - } - if saveErr != nil { - m.appendLine(styleErr.Render("/save: " + saveErr.Error())) - break - } - m.appendLine(styleHelp.Render(fmt.Sprintf("saved %d messages → %s", len(m.history), parts[1]))) - case "/session": - m.appendLine(m.session.Dir) - case "/events": - events, err := session.LoadEvents(m.workdir, m.session.ID, 20) - if err != nil { - m.appendLine(styleErr.Render("/events: " + err.Error())) - break - } - if len(events) == 0 { - m.appendLine(styleHelp.Render("(no events recorded yet)")) - break - } - for _, e := range events { - m.appendLine(styleHelp.Render(fmt.Sprintf(" %s step=%d tool=%s space=%s status=%s", e.Type, e.Step, e.Tool, e.SpaceID, e.Status))) - } - case "/space": - if len(parts) != 2 { - m.appendLine(styleErr.Render("/space: usage: /space ")) - break - } - p, err := continuity.BuildContextPacket(m.workdir, m.project.ID, parts[1]) - if err != nil { - m.appendLine(styleErr.Render("/space: " + err.Error())) - break - } - m.appendLine(styleHelp.Render(fmt.Sprintf("%s (%s): %s", p.Space.ID, p.Space.Status, p.Space.Name))) - m.appendLine(styleHelp.Render(fmt.Sprintf(" %d decisions · %d feedback · %d episodes · %d assets", len(p.RecentDecisions), len(p.RecentFeedback), len(p.RecentEpisodes), len(p.Assets)))) - case "/compact": - if len(parts) != 2 { - m.appendLine(styleErr.Render("/compact: usage: /compact ")) - break - } - body, err := continuity.BuildCompactionDraft(m.workdir, m.project.ID, parts[1]) - if err != nil { - m.appendLine(styleErr.Render("/compact: " + err.Error())) - break - } - m.appendMarkdown(body) - default: - m.appendLine(styleErr.Render("unknown command: " + cmd + " (try /help)")) - } - m.appendLine("") - return nil -} - -func (m *Model) insertInputNewline() { - m.textarea.InsertString("\n") - m.resetInputHistoryBrowse() - m.paletteVisible = false - m.recomputeLayout() -} - -// cancelCurrentTurn aborts the in-flight runtime.Run. The worker will -// eventually emit a runDoneMsg with context.Canceled. -func (m *Model) cancelCurrentTurn(reason string) { - if m.cancelTurn != nil { - m.cancelTurn() - m.cancelTurn = nil - } - m.appendLine(styleWarn.Render("[" + reason + "]")) -} - -// headerLine renders the top-of-screen identity bar — project + active -// LLM + active image model. Replaces the old bottom status row so this -// info stays anchored at the top-left like the user's terminal title. -func (m *Model) headerLine() string { - parts := []string{"openmelon", m.project.ID} - if m.llmTag != "" { - parts = append(parts, m.llmTag) - } - if m.imageTag != "" { - parts = append(parts, "img:"+m.imageTag) - } - return styleStatusBar.Render(strings.Join(parts, " · ")) -} - -// --- rendering helpers --- - -// renderToolCall returns the " ⏺ name(args)" line. -func renderToolCall(c llm.ToolCall) string { - args := truncateOneLine(prettyJSON(c.Arguments), 120) - return " " + styleToolName.Render("⏺ "+c.Name) + styleToolArgs.Render("("+args+")") -} - -// renderToolResult returns the " ⎿ result" line, dimmed. -func renderToolResult(_ llm.ToolCall, content string, err error) string { - if err != nil { - return " " + styleErr.Render("⎿ error: "+err.Error()) - } - if msg := toolErrorMessage(content); msg != "" { - return " " + styleErr.Render("⎿ error: "+truncateOneLine(msg, 240)) - } - trimmed := strings.TrimSpace(content) - switch trimmed { - case "[]", "{}", "null", `""`: - return " " + styleToolResult.Render("⎿ (no results)") - } - return " " + styleToolResult.Render("⎿ "+truncateOneLine(content, 240)) -} - -func toolErrorMessage(content string) string { - var obj map[string]any - if err := json.Unmarshal([]byte(content), &obj); err != nil { - return "" - } - raw, ok := obj["error"] - if !ok || raw == nil { - return "" - } - switch v := raw.(type) { - case string: - return strings.TrimSpace(v) - default: - b, err := json.Marshal(v) - if err != nil { - return strings.TrimSpace(fmt.Sprint(v)) - } - return strings.TrimSpace(string(b)) - } -} - -func prettyJSON(raw json.RawMessage) string { - if len(raw) == 0 { - return "" - } - var v any - if err := json.Unmarshal(raw, &v); err != nil { - return string(raw) - } - b, err := json.Marshal(v) - if err != nil { - return string(raw) - } - return string(b) -} - -func truncateOneLine(s string, n int) string { - s = strings.ReplaceAll(s, "\n", " ") - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -func shortSession(dir string) string { - parts := strings.Split(dir, "/") - if len(parts) == 0 { - return dir - } - return parts[len(parts)-1] -} - -// errIsCanceled checks if err is/wraps context.Canceled. Avoid importing -// context just for this in a place where errors.Is would do. -func errIsCanceled(err error) bool { - return err != nil && (err == context.Canceled || strings.Contains(err.Error(), "context canceled")) -} - -// --- spinner verb tick --- - -// elapsedTickMsg fires once a second so the spinner row's elapsed -// timer updates. The TUI re-schedules another tick from Update only -// while state == stateRunning, so the timer naturally stops when the -// turn ends. -type elapsedTickMsg struct{} - -func scheduleElapsedTick() tea.Cmd { - return tea.Tick(1*time.Second, func(time.Time) tea.Msg { return elapsedTickMsg{} }) -} - -// ===================================================================== -// /model and /model-image selectors -// ===================================================================== - -// modelSelectorRows returns the list of preset ids the active selector -// should show, plus a final "Custom..." sentinel (""). -func (m *Model) modelSelectorRows() []string { - info, ok := onboard.ProviderBySlug(m.activeSelectorProvider()) - if !ok { - return []string{""} - } - var presets []onboard.Preset - if m.state == stateImageModelSelect { - presets = info.ImagePresets - } else { - presets = info.LLMPresets - } - out := make([]string, 0, len(presets)+1) - for _, p := range presets { - out = append(out, p.ID) - } - out = append(out, "") // sentinel for "Custom…" - return out -} - -// activeSelectorProvider picks which provider's presets to use. -// Image selector reads imageProvider; otherwise the LLM provider. -func (m *Model) activeSelectorProvider() string { - if m.state == stateImageModelSelect { - if m.imageProvider != "" { - return m.imageProvider - } - // User has no image provider yet — default to the LLM provider - // since most providers (openrouter / openai) support both. - return m.provider - } - return m.provider -} - -// openModelSelector switches the TUI into one of the model-select -// states. The current model gets the cursor highlight (shows ✓ next -// to the row when rendered). -func (m *Model) openModelSelector(image bool) { - if image { - m.state = stateImageModelSelect - } else { - m.state = stateModelSelect - } - rows := m.modelSelectorRows() - cur := m.llmModel - if image { - cur = m.imageModel - } - m.selectorCursor = 0 - for i, id := range rows { - if id != "" && id == cur { - m.selectorCursor = i - break - } - } - m.textarea.Blur() - m.recomputeLayout() -} - -// openModelCustom transitions from the preset list into the "type a -// model id" state. -func (m *Model) openModelCustom() { - if m.state == stateModelSelect { - m.state = stateModelCustom - } else if m.state == stateImageModelSelect { - m.state = stateImageModelCustom - } - ti := textinput.New() - ti.Placeholder = "vendor/model-id" - ti.CharLimit = 200 - ti.Width = 60 - cur := m.llmModel - if m.state == stateImageModelCustom { - cur = m.imageModel - } - if cur != "" { - ti.SetValue(cur) - } - ti.Focus() - m.customModelInput = ti - m.recomputeLayout() -} - -// closeSelector returns to the idle / running state. -func (m *Model) closeSelector() { - m.state = stateIdle - m.textarea.Focus() - m.recomputeLayout() -} - -// applySelectedModel commits the chosen id by calling back into -// cmd_repl's rebuild closure. Logs success / failure into the -// transcript. -func (m *Model) applySelectedModel(id string, image bool) { - if image { - // Empty id with image mode = stay (no-op). To disable image - // generation entirely, we'd need a separate "Disable" row; - // skipping for now. - if m.rebuildImageModel == nil { - m.appendLine(styleErr.Render("openmelon: image model swap not wired")) - return - } - tag, err := m.rebuildImageModel(m.activeSelectorProvider(), id) - if err != nil { - m.appendLine(styleErr.Render("image model swap failed: " + err.Error())) - return - } - m.imageModel = id - m.imageProvider = m.activeSelectorProvider() - m.imageTag = tag - m.appendLine(styleHelp.Render("(image model: " + id + ")")) - return - } - if m.rebuildLLM == nil { - m.appendLine(styleErr.Render("openmelon: LLM swap not wired")) - return - } - tag, err := m.rebuildLLM(id) - if err != nil { - m.appendLine(styleErr.Render("LLM swap failed: " + err.Error())) - return - } - m.llmModel = id - m.llmTag = tag - m.appendLine(styleHelp.Render("(LLM: " + id + ")")) -} - -// updateSelector handles key input for the preset-list selector states. -func (m *Model) updateSelector(msg tea.KeyMsg) tea.Cmd { - rows := m.modelSelectorRows() - switch msg.String() { - case "esc", "ctrl+c": - m.closeSelector() - return nil - case "up", "k": - if m.selectorCursor > 0 { - m.selectorCursor-- - } - case "down", "j": - if m.selectorCursor < len(rows)-1 { - m.selectorCursor++ - } - case "enter": - image := m.state == stateImageModelSelect - picked := rows[m.selectorCursor] - if picked == "" { - // Custom row. - m.openModelCustom() - return nil - } - m.applySelectedModel(picked, image) - m.closeSelector() - } - // Number-key shortcut (1-9). - if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' { - n := int(msg.String()[0] - '1') - if n < len(rows) { - image := m.state == stateImageModelSelect - picked := rows[n] - if picked == "" { - m.openModelCustom() - return nil - } - m.applySelectedModel(picked, image) - m.closeSelector() - } - } - return nil -} - -// updateCustomInput handles key input for the "type a model id" state. -func (m *Model) updateCustomInput(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "esc", "ctrl+c": - m.closeSelector() - return nil - case "enter": - val := strings.TrimSpace(m.customModelInput.Value()) - if val == "" { - return nil - } - image := m.state == stateImageModelCustom - m.applySelectedModel(val, image) - m.closeSelector() - return nil - } - var cmd tea.Cmd - m.customModelInput, cmd = m.customModelInput.Update(msg) - return cmd -} - -// renderSelector renders the preset-list selector overlay (replaces -// the input area while a selector is active). -func (m *Model) renderSelector() string { - var b strings.Builder - header := "Select LLM model" - desc := "Switch the model used by this and future turns. Persists to project.json." - if m.state == stateImageModelSelect { - header = "Select image model" - desc = "Switch the model used by generate_image. Persists to project.json." - } - b.WriteString(headerStyle.Render(header)) - b.WriteString("\n") - b.WriteString(styleHelp.Render(desc)) - b.WriteString("\n\n") - - info, _ := onboard.ProviderBySlug(m.activeSelectorProvider()) - var presets []onboard.Preset - current := m.llmModel - if m.state == stateImageModelSelect { - presets = info.ImagePresets - current = m.imageModel - } else { - presets = info.LLMPresets - } - rows := append([]onboard.Preset(nil), presets...) - rows = append(rows, onboard.Preset{ID: "", Subtitle: "Type your own model id"}) - - for i, r := range rows { - marker := " " - num := fmt.Sprintf("%d.", i+1) - title := r.ID - if title == "" { - title = "Custom…" - } - check := "" - if r.ID != "" && r.ID == current { - check = " ✓" - } - if i == m.selectorCursor { - marker = stylePromptArrow.Render("› ") - num = stylePaletteActive.Render(num) - title = stylePaletteActive.Render(title + check) - } else { - num = stylePaletteName.Render(num) - title = title + check - } - fmt.Fprintf(&b, "%s%s %-30s %s\n", marker, num, title, styleHelp.Render(r.Subtitle)) - } - b.WriteString("\n") - b.WriteString(styleHelp.Render("Enter to confirm · Esc to cancel · 1-N shortcut")) - b.WriteString("\n") - return b.String() -} - -// renderCustomInput renders the textinput overlay. -func (m *Model) renderCustomInput() string { - var b strings.Builder - header := "Custom LLM model id" - if m.state == stateImageModelCustom { - header = "Custom image model id" - } - b.WriteString(headerStyle.Render(header)) - b.WriteString("\n") - b.WriteString(styleHelp.Render("Type the vendor-specific id (e.g. openai/gpt-5.5, google/gemini-3-pro-preview).")) - b.WriteString("\n\n") - b.WriteString(m.customModelInput.View()) - b.WriteString("\n\n") - b.WriteString(styleHelp.Render("Enter to confirm · Esc to cancel")) - b.WriteString("\n") - return b.String() -} - -// inSelector returns true when the model is in any selector state. -func (m *Model) inSelector() bool { - switch m.state { - case stateModelSelect, stateModelCustom, stateImageModelSelect, stateImageModelCustom: - return true - } - return false -} - -// ===================================================================== -// Approval modal (bash tool confirmation) -// ===================================================================== - -// approvalOptions are the three rows of the bash approval modal. The -// "always" row is data-driven so renderApproval can include the -// command's first binary in its label. -func (m *Model) approvalOptions() []string { - binary := "this binary" - if m.approvalReq != nil && m.approvalReq.Binary != "" { - binary = m.approvalReq.Binary - } - return []string{ - "Yes", - fmt.Sprintf("Yes, always allow `%s` this session", binary), - "No", - } -} - -// updateApproval handles key input while a bash approval is pending. -// 1 / Enter / y → approve once, 2 → approve + always for this binary, -// 3 / n / Esc → deny. Up/Down navigates choices; PgUp/PgDn scrolls -// the command/details pane. -func (m *Model) updateApproval(msg tea.KeyMsg) { - max := len(m.approvalOptions()) - 1 - switch msg.String() { - case "up", "k": - if m.approvalCursor > 0 { - m.approvalCursor-- - } - case "down", "j": - if m.approvalCursor < max { - m.approvalCursor++ - } - case "pgup", "ctrl+u": - m.scrollApproval(-m.approvalBodyRows()) - case "pgdown", "ctrl+d": - m.scrollApproval(m.approvalBodyRows()) - case "home": - m.approvalScroll = 0 - case "end": - m.approvalScroll = m.approvalMaxScroll() - case "1", "y", "Y": - m.approvalCursor = 0 - m.answerApproval(true, false) - case "2": - m.approvalCursor = 1 - m.answerApproval(true, true) - case "3", "n", "N", "esc": - m.approvalCursor = max - m.answerApproval(false, false) - case "enter": - switch m.approvalCursor { - case 0: - m.answerApproval(true, false) - case 1: - m.answerApproval(true, true) - default: - m.answerApproval(false, false) - } - } -} - -// answerApproval sends the user's choice back to the worker goroutine -// and transitions back to stateRunning so the spinner / activity row -// resumes. -func (m *Model) answerApproval(approved, always bool) { - if m.approvalReq == nil { - return - } - m.approvalReq.Reply <- tools.ApprovalDecision{Approved: approved, Always: always} - m.approvalReq = nil - m.approvalScroll = 0 - m.state = stateRunning - switch { - case approved && always: - m.activityText = "Running bash (allowed for session)" - case approved: - m.activityText = "Running bash" - default: - m.activityText = "Bash denied" - } - m.recomputeLayout() -} - -func (m *Model) approvalBodyRows() int { - rows := 8 - if m.height > 0 { - rows = m.height / 3 - } - if rows < 4 { - rows = 4 - } - if rows > 14 { - rows = 14 - } - return rows -} - -func (m *Model) approvalContentWidth() int { - width := m.width - if width <= 0 { - width = 80 - } - width -= 4 - if width < 20 { - width = 20 - } - return width -} - -func (m *Model) approvalContentLines() []string { - r := m.approvalReq - if r == nil { - return nil - } - width := m.approvalContentWidth() - var lines []string - if strings.TrimSpace(r.Description) != "" { - lines = append(lines, styleHelp.Render("Reason")) - lines = append(lines, wrapPlainText(r.Description, width)...) - lines = append(lines, "") - } - lines = append(lines, styleHelp.Render("Command")) - for _, line := range strings.Split(r.Command, "\n") { - if line == "" { - lines = append(lines, " ") - continue - } - for _, wrapped := range wrapPlainText(line, width-2) { - lines = append(lines, " "+wrapped) - } - } - return lines -} - -func (m *Model) approvalMaxScroll() int { - lines := m.approvalContentLines() - max := len(lines) - m.approvalBodyRows() - if max < 0 { - return 0 - } - return max -} - -func (m *Model) clampApprovalScroll() { - max := m.approvalMaxScroll() - if m.approvalScroll < 0 { - m.approvalScroll = 0 - } - if m.approvalScroll > max { - m.approvalScroll = max - } -} - -func (m *Model) scrollApproval(delta int) { - m.approvalScroll += delta - m.clampApprovalScroll() -} - -func wrapPlainText(text string, width int) []string { - if width < 8 { - width = 8 - } - var out []string - for _, line := range strings.Split(text, "\n") { - if line == "" { - out = append(out, "") - continue - } - out = append(out, strings.Split(ansi.Wrap(line, width, "/._=&?:,"), "\n")...) - } - if len(out) == 0 { - return []string{""} - } - return out -} - -// ===================================================================== -// Skill picker (/skill) -// ===================================================================== - -func (m *Model) openSkillSelector() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - skills, err := skillplus.ListSkills(ctx) - if err != nil { - m.skillLoadErr = err.Error() - } else { - m.skillLoadErr = "" - } - m.skillList = skills - m.skillCursor = 0 - for i, s := range skills { - if s.ID == m.activeSkill { - m.skillCursor = i - break - } - } - m.state = stateSkillSelect - m.recomputeLayout() -} - -func (m *Model) updateSkillSelect(msg tea.KeyMsg) { - max := len(m.skillList) // last row = "(none)" - switch msg.String() { - case "esc", "ctrl+c": - m.state = stateIdle - m.recomputeLayout() - return - case "up", "k": - if m.skillCursor > 0 { - m.skillCursor-- - } - case "down", "j": - if m.skillCursor < max { - m.skillCursor++ - } - case "enter": - m.commitSkillPick() - } - if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' { - n := int(msg.String()[0] - '1') - if n <= max { - m.skillCursor = n - m.commitSkillPick() - } - } -} - -func (m *Model) commitSkillPick() { - if m.skillCursor == len(m.skillList) { - // "(none)" — clear selection. - if m.activeSkill != "" { - m.appendLine(styleHelp.Render("(skill cleared: " + m.activeSkill + ")")) - m.activeSkill = "" - } - } else if m.skillCursor < len(m.skillList) { - picked := m.skillList[m.skillCursor] - m.activeSkill = picked.ID - m.appendLine(styleHelp.Render("(skill: " + picked.ID + ") — applies to your next message")) - } - m.state = stateIdle - m.recomputeLayout() -} - -func (m *Model) renderSkillSelect() string { - var b strings.Builder - b.WriteString(headerStyle.Render("Select a skillplus package")) - b.WriteString("\n") - b.WriteString(styleHelp.Render("Picked skill is applied to your next message — the model is told to compile_skill it before responding. Pick (none) to clear.")) - b.WriteString("\n\n") - if m.skillLoadErr != "" { - b.WriteString(styleErr.Render("error listing skills: " + m.skillLoadErr)) - b.WriteString("\n\n") - } - rows := append([]skillplus.SkillInfo(nil), m.skillList...) - for i, s := range rows { - marker := " " - num := fmt.Sprintf("%d.", i+1) - title := s.ID - check := "" - if s.ID == m.activeSkill { - check = " ✓" - } - if i == m.skillCursor { - marker = stylePromptArrow.Render("› ") - num = stylePaletteActive.Render(num) - title = stylePaletteActive.Render(title + check) - } else { - title = title + check - } - desc := s.Description - if len(desc) > 80 { - desc = desc[:80] + "…" - } - fmt.Fprintf(&b, "%s%s %-28s %s\n", marker, num, title, styleHelp.Render(desc)) - } - // "(none)" row. - { - i := len(rows) - marker := " " - num := fmt.Sprintf("%d.", i+1) - title := "(none)" - if i == m.skillCursor { - marker = stylePromptArrow.Render("› ") - num = stylePaletteActive.Render(num) - title = stylePaletteActive.Render(title) - } - fmt.Fprintf(&b, "%s%s %-28s %s\n", marker, num, title, styleHelp.Render("don't apply any skill")) - } - b.WriteString("\n") - b.WriteString(styleHelp.Render("Enter to confirm · Esc to cancel · 1-N shortcut")) - b.WriteString("\n") - return b.String() -} - -// ===================================================================== -// Settings panel (/settings) -// ===================================================================== - -// bashModeRows is the ordered list shown in /settings → Bash perms. -var bashModeRows = []struct { - mode projectx.BashPermissionMode - title string - desc string -}{ - {projectx.BashModeStrict, "Strict", - "Every bash needs your approval. Judge LLM auto-blocks anything destructive."}, - {projectx.BashModeAuto, "Auto-judge", - "Judge LLM auto-runs read-only commands; you approve writes; destructive ones blocked."}, - {projectx.BashModeTrusted, "Trusted (DANGEROUS)", - "Run any bash without asking. Like Claude Code's --dangerously-skip-permissions. Use only in throwaway projects."}, -} - -var reasoningRows = []struct { - effort string - title string - desc string -}{ - {"", "Auto", - "Use OpenMelon's model-aware default. GPT-5-family models default to xhigh."}, - {"medium", "Medium", - "Balanced reasoning depth for normal iteration."}, - {"high", "High", - "Deeper reasoning for planning, code, and tool-heavy tasks."}, - {"xhigh", "XHigh", - "Maximum reasoning hint when the endpoint supports it."}, -} - -func (m *Model) openSettings() { - m.state = stateSettings - m.settingsCursor = 0 - for i, r := range bashModeRows { - if r.mode == m.bashMode { - m.settingsCursor = i + 1 - break - } - } - m.recomputeLayout() -} - -func (m *Model) updateSettings(msg tea.KeyMsg) { - max := len(settingsRows()) - 1 - switch msg.String() { - case "esc", "ctrl+c": - m.state = stateIdle - m.recomputeLayout() - case "up", "k": - if m.settingsCursor > 0 { - m.settingsCursor-- - } - m.skipSettingsSection(-1) - case "down", "j": - if m.settingsCursor < max { - m.settingsCursor++ - } - m.skipSettingsSection(1) - case "enter": - m.commitSettingsPick() - m.state = stateIdle - m.recomputeLayout() - } - // Number-key shortcut. - if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' { - if idx, ok := settingsRowIndexForNumber(int(msg.String()[0] - '0')); ok && idx <= max { - m.settingsCursor = idx - } - } -} - -func (m *Model) skipSettingsSection(direction int) { - rows := settingsRows() - if len(rows) == 0 { - return - } - if m.settingsCursor < 0 { - m.settingsCursor = 0 - } - if m.settingsCursor >= len(rows) { - m.settingsCursor = len(rows) - 1 - } - if rows[m.settingsCursor].kind != "section" { - return - } - if direction < 0 { - for m.settingsCursor > 0 && rows[m.settingsCursor].kind == "section" { - m.settingsCursor-- - } - if rows[m.settingsCursor].kind == "section" && len(rows) > 1 { - m.settingsCursor = 1 - } - return - } - for m.settingsCursor < len(rows)-1 && rows[m.settingsCursor].kind == "section" { - m.settingsCursor++ - } - if rows[m.settingsCursor].kind == "section" && len(rows) > 1 { - m.settingsCursor = len(rows) - 1 - } -} - -type settingsRow struct { - kind string - title string - desc string -} - -func settingsRows() []settingsRow { - rows := make([]settingsRow, 0, len(bashModeRows)+len(reasoningRows)+2) - rows = append(rows, settingsRow{kind: "section", title: "Bash permissions"}) - for _, r := range bashModeRows { - rows = append(rows, settingsRow{kind: "bash:" + string(r.mode), title: r.title, desc: r.desc}) - } - rows = append(rows, settingsRow{kind: "section", title: "Reasoning effort"}) - for _, r := range reasoningRows { - rows = append(rows, settingsRow{kind: "reasoning:" + r.effort, title: r.title, desc: r.desc}) - } - return rows -} - -func settingsRowIndexForNumber(n int) (int, bool) { - if n <= 0 { - return 0, false - } - count := 0 - for i, row := range settingsRows() { - if row.kind == "section" { - continue - } - count++ - if count == n { - return i, true - } - } - return 0, false -} - -func (m *Model) commitSettingsPick() { - rows := settingsRows() - if m.settingsCursor < 0 || m.settingsCursor >= len(rows) || m.saveSettings == nil { - return - } - row := rows[m.settingsCursor] - next := projectx.Settings{ - BashPermissionMode: m.bashMode, - ReasoningEffort: m.reasoningEffort, - } - switch { - case strings.HasPrefix(row.kind, "bash:"): - next.BashPermissionMode = projectx.BashPermissionMode(strings.TrimPrefix(row.kind, "bash:")) - case strings.HasPrefix(row.kind, "reasoning:"): - next.ReasoningEffort = strings.TrimPrefix(row.kind, "reasoning:") - default: - return - } - if err := m.saveSettings(next); err != nil { - m.appendLine(styleErr.Render("settings save failed: " + err.Error())) - return - } - m.bashMode = next.EffectiveBashMode() - m.reasoningEffort = next.EffectiveReasoningEffort() - if m.reasoningEffort == "" { - m.appendLine(styleHelp.Render("(settings: bash=" + string(m.bashMode) + " reasoning=auto)")) - } else { - m.appendLine(styleHelp.Render("(settings: bash=" + string(m.bashMode) + " reasoning=" + m.reasoningEffort + ")")) - } -} - -func (m *Model) renderSettings() string { - var b strings.Builder - b.WriteString(headerStyle.Render("Settings")) - b.WriteString("\n\n") - b.WriteString(styleHelp.Render("Persists to project.json.")) - b.WriteString("\n\n") - num := 0 - for i, r := range settingsRows() { - if r.kind == "section" { - b.WriteString(headerStyle.Render(r.title)) - b.WriteString("\n") - continue - } - num++ - marker := " " - label := fmt.Sprintf("%d.", num) - title := r.title - check := "" - if strings.HasPrefix(r.kind, "bash:") && strings.TrimPrefix(r.kind, "bash:") == string(m.bashMode) { - check = " ✓" - } - if strings.HasPrefix(r.kind, "reasoning:") && strings.TrimPrefix(r.kind, "reasoning:") == m.reasoningEffort { - check = " ✓" - } - if i == m.settingsCursor { - marker = stylePromptArrow.Render("› ") - label = stylePaletteActive.Render(label) - title = stylePaletteActive.Render(r.title + check) - } else { - title = r.title + check - } - fmt.Fprintf(&b, "%s%s %s\n", marker, label, title) - fmt.Fprintf(&b, " %s\n", styleHelp.Render(r.desc)) - } - b.WriteString("\n") - b.WriteString(styleHelp.Render("Enter to set · Esc to close · ↑/↓ select · 1-7 shortcut")) - b.WriteString("\n") - return b.String() -} - -// renderApproval draws the bash-confirmation panel. -func (m *Model) renderApproval() string { - r := m.approvalReq - if r == nil { - return "" - } - m.clampApprovalScroll() - lines := m.approvalContentLines() - bodyRows := m.approvalBodyRows() - start := m.approvalScroll - end := start + bodyRows - if end > len(lines) { - end = len(lines) - } - var b strings.Builder - b.WriteString(headerStyle.Render("Bash approval required")) - b.WriteString("\n") - if len(lines) > bodyRows { - fmt.Fprintf(&b, "%s\n", styleHelp.Render(fmt.Sprintf("Details %d-%d/%d · PgUp/PgDn scroll", start+1, end, len(lines)))) - } else { - b.WriteString(styleHelp.Render("Review the command before approving.")) - b.WriteString("\n") - } - b.WriteString("\n") - for _, line := range lines[start:end] { - b.WriteString(line) - b.WriteString("\n") - } - for i := end - start; i < bodyRows; i++ { - b.WriteString("\n") - } - b.WriteString("\nDo you want to proceed?\n") - for i, opt := range m.approvalOptions() { - marker := " " - num := fmt.Sprintf("%d.", i+1) - title := opt - if i == m.approvalCursor { - marker = stylePromptArrow.Render("› ") - num = stylePaletteActive.Render(num) - title = stylePaletteActive.Render(opt) - } - fmt.Fprintf(&b, "%s%s %s\n", marker, num, title) - } - b.WriteString("\n") - b.WriteString(styleHelp.Render("Enter confirm · Esc deny · 1/2/3 shortcut · PgUp/PgDn details")) - b.WriteString("\n") - return b.String() -} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go deleted file mode 100644 index 75442ff..0000000 --- a/internal/tui/model_test.go +++ /dev/null @@ -1,348 +0,0 @@ -package tui - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -func TestHandleSlashSaveWritesHistoryAsJSONL(t *testing.T) { - m := newModel(modelInit{}) - m.history = []llm.Message{ - {Role: llm.RoleUser, Content: "hello"}, - {Role: llm.RoleAssistant, Content: "hi there"}, - } - - path := filepath.Join(t.TempDir(), "conversation.jsonl") - if cmd := m.handleSlash("/save " + path); cmd != nil { - t.Fatalf("handleSlash returned command %T", cmd) - } - - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read saved file: %v", err) - } - - lines := strings.Split(strings.TrimSpace(string(body)), "\n") - if len(lines) != len(m.history) { - t.Fatalf("saved %d messages, want %d", len(lines), len(m.history)) - } - for i, line := range lines { - var got llm.Message - if err := json.Unmarshal([]byte(line), &got); err != nil { - t.Fatalf("line %d is not JSON: %v", i, err) - } - if got.Role != m.history[i].Role || got.Content != m.history[i].Content || got.ToolCallID != m.history[i].ToolCallID || len(got.ToolCalls) != len(m.history[i].ToolCalls) { - t.Fatalf("line %d = %#v, want %#v", i, got, m.history[i]) - } - } - - if got := m.transcript.String(); !strings.Contains(got, "saved 2 messages") || !strings.Contains(got, filepath.Base(path)) { - t.Fatalf("transcript %q does not report saved path", got) - } -} - -func TestHandleSlashSaveRequiresPath(t *testing.T) { - m := newModel(modelInit{}) - - if cmd := m.handleSlash("/save"); cmd != nil { - t.Fatalf("handleSlash returned command %T", cmd) - } - - if got := m.transcript.String(); !strings.Contains(got, "/save: usage: /save ") { - t.Fatalf("transcript %q does not report usage", got) - } -} - -func TestHandleSlashSaveReportsCreateError(t *testing.T) { - m := newModel(modelInit{}) - path := t.TempDir() - - if cmd := m.handleSlash("/save " + path); cmd != nil { - t.Fatalf("handleSlash returned command %T", cmd) - } - - if got := m.transcript.String(); !strings.Contains(got, "/save:") { - t.Fatalf("transcript %q does not report save error", got) - } -} - -func TestRenderToolResultHighlightsJSONError(t *testing.T) { - rendered := renderToolResult(llm.ToolCall{}, `{"error":"image model rejected request"}`, nil) - if !strings.Contains(rendered, "error: image model rejected request") { - t.Fatalf("rendered result does not expose error: %q", rendered) - } - if strings.Contains(rendered, `{"error"`) { - t.Fatalf("rendered result should show the error text, not raw JSON: %q", rendered) - } -} - -func TestResumeHistorySeparatesToolTurns(t *testing.T) { - m := newModel(modelInit{ - InitialHistory: []llm.Message{ - {Role: llm.RoleUser, Content: "make assets"}, - {Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{Name: "generate_image", Arguments: json.RawMessage(`{"label":"one"}`)}}}, - {Role: llm.RoleTool, Content: `{"path":"/tmp/one.png","sha256":"abc"}`}, - {Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{Name: "register_asset", Arguments: json.RawMessage(`{"id":"asset-one"}`)}}}, - {Role: llm.RoleTool, Content: `{"id":"asset-one","status":"canonical"}`}, - }, - }) - - if cmd := m.Init(); cmd == nil { - t.Fatal("Init returned nil command") - } - got := m.transcript.String() - if !strings.Contains(got, "> make assets\n\n") { - t.Fatalf("user message is not separated: %q", got) - } - if !strings.Contains(got, "one.png") || !strings.Contains(got, "register_asset") { - t.Fatalf("history missing rendered tool content: %q", got) - } - if strings.Count(got, "\n\n") < 3 { - t.Fatalf("history lacks visual gaps between turns: %q", got) - } -} - -func TestInputHistoryRecallsPreviousPrompts(t *testing.T) { - m := newModel(modelInit{}) - m.recordInputHistory("first") - m.recordInputHistory("second") - - if !m.handleInputHistoryKey(tea.KeyMsg{Type: tea.KeyUp}) { - t.Fatal("expected up to recall history") - } - if got := m.textarea.Value(); got != "second" { - t.Fatalf("first recall = %q", got) - } - if !m.handleInputHistoryKey(tea.KeyMsg{Type: tea.KeyUp}) { - t.Fatal("expected second up to recall older history") - } - if got := m.textarea.Value(); got != "first" { - t.Fatalf("second recall = %q", got) - } - if !m.handleInputHistoryKey(tea.KeyMsg{Type: tea.KeyDown}) { - t.Fatal("expected down to move forward") - } - if got := m.textarea.Value(); got != "second" { - t.Fatalf("down recall = %q", got) - } - if !m.handleInputHistoryKey(tea.KeyMsg{Type: tea.KeyDown}) { - t.Fatal("expected down to restore draft") - } - if got := m.textarea.Value(); got != "" { - t.Fatalf("restored draft = %q", got) - } -} - -func TestNewlineKeyInsertsNewlineWithoutSubmitting(t *testing.T) { - m := newModel(modelInit{Runtime: &runtime.Runtime{}}) - m.textarea.SetValue("first") - m.runner = func(ctx context.Context, in runtime.RunInput) (*runtime.RunResult, error) { - t.Fatal("newline should not submit") - return nil, nil - } - - model, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) - if cmd != nil { - t.Fatalf("newline returned command %T", cmd) - } - m = model.(*Model) - if got := m.textarea.Value(); got != "first\n" { - t.Fatalf("textarea = %q", got) - } -} - -func TestEnterStillSubmits(t *testing.T) { - m := newModel(modelInit{Runtime: &runtime.Runtime{}}) - m.textarea.SetValue("send me") - m.runner = func(ctx context.Context, in runtime.RunInput) (*runtime.RunResult, error) { - return &runtime.RunResult{Messages: []llm.Message{{Role: llm.RoleUser, Content: in.UserInput}}}, nil - } - - model, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - if cmd == nil { - t.Fatal("enter did not return submit command") - } - m = model.(*Model) - if m.state != stateRunning { - t.Fatalf("state = %v, want running", m.state) - } - if got := m.textarea.Value(); got != "" { - t.Fatalf("textarea = %q, want reset", got) - } -} - -func TestLongInputSoftWrapsByGrowingTextarea(t *testing.T) { - m := newModel(modelInit{Runtime: &runtime.Runtime{}}) - m.resize(20, 20) - m.textarea.SetValue(strings.Repeat("a", 40)) - m.recomputeLayout() - - if got := m.textarea.Height(); got < 3 { - t.Fatalf("textarea height = %d, want soft-wrapped growth", got) - } - if got := inputVisualLines(strings.Repeat("a", 40), inputTextWidth(20)); got < 3 { - t.Fatalf("visual lines = %d, want >= 3", got) - } -} - -func TestAppendLineWrapsLongStatusText(t *testing.T) { - m := newModel(modelInit{}) - m.resize(24, 20) - m.appendLine(styleErr.Render(strings.Repeat("x", 60))) - - got := m.transcript.String() - if lines := strings.Count(got, "\n"); lines < 3 { - t.Fatalf("transcript line count = %d, want wrapped output; body=%q", lines, got) - } -} - -func TestTranscriptReflowsOnResize(t *testing.T) { - m := newModel(modelInit{}) - text := strings.Repeat("word/", 14) - - m.resize(24, 20) - m.appendLine(text) - narrowLines := strings.Count(m.transcript.String(), "\n") - - m.resize(80, 20) - wideLines := strings.Count(m.transcript.String(), "\n") - if wideLines >= narrowLines { - t.Fatalf("resize did not reflow transcript: narrow=%d wide=%d body=%q", narrowLines, wideLines, m.transcript.String()) - } -} - -func TestViewportKeepsUserScrollWhenTranscriptUpdates(t *testing.T) { - m := newModel(modelInit{}) - m.resize(30, 12) - for i := range 30 { - m.appendLine("line " + string(rune('a'+(i%26)))) - } - m.viewport.HalfPageUp() - m.updateScrollAnchor() - before := m.viewport.YOffset - - m.appendLine("new line while reviewing history") - if got := m.viewport.YOffset; got != before { - t.Fatalf("viewport y offset = %d, want preserved %d", got, before) - } - if m.anchoredBottom { - t.Fatal("scroll anchor should be disabled after user scrolls up") - } -} - -func TestRenderPlainTranscriptStripsANSI(t *testing.T) { - m := newModel(modelInit{}) - m.resize(80, 20) - m.appendLine(styleErr.Render("error: failed")) - m.appendMarkdown("**bold** and `code`") - - got := m.renderPlainTranscript() - if strings.Contains(got, "\x1b[") { - t.Fatalf("plain transcript still contains ansi: %q", got) - } - for _, want := range []string{"error: failed", "bold", "code"} { - if !strings.Contains(got, want) { - t.Fatalf("plain transcript missing %q: %q", want, got) - } - } -} - -func TestApprovalCanScrollLongCommand(t *testing.T) { - m := newModel(modelInit{}) - m.resize(36, 24) - m.state = stateApprovalPending - m.approvalReq = &approvalRequestMsg{ - Description: "review this command", - Command: strings.Join([]string{"line-01", "line-02", "line-03", "line-04", "line-05", "line-06", "line-07", "line-08", "line-09", "line-10"}, "\n"), - Binary: "bash", - Reply: make(chan tools.ApprovalDecision, 1), - } - - before := m.renderApproval() - if !strings.Contains(before, "line-01") { - t.Fatalf("initial approval view missing first command line: %q", before) - } - m.updateApproval(tea.KeyMsg{Type: tea.KeyPgDown}) - after := m.renderApproval() - if m.approvalScroll == 0 { - t.Fatal("approval scroll did not move") - } - if !strings.Contains(after, "line-10") { - t.Fatalf("scrolled approval view missing later command line: %q", after) - } -} - -func TestQueueInputDrainsIntoRuntime(t *testing.T) { - m := newModel(modelInit{Runtime: &runtime.Runtime{}}) - m.queueInput("make it shorter") - - got := m.drainQueuedInput() - if len(got) != 1 || got[0] != "make it shorter" { - t.Fatalf("drain = %#v", got) - } - if got := m.drainQueuedInput(); len(got) != 0 { - t.Fatalf("second drain = %#v", got) - } - m.consumeAppliedInputAcks() - if m.pendingInputs != 0 { - t.Fatalf("pending inputs = %d", m.pendingInputs) - } -} - -func TestSubmitInstallsRuntimeDrainHook(t *testing.T) { - rt := &runtime.Runtime{} - m := newModel(modelInit{Runtime: rt}) - m.queueInput("queued before model call") - m.runner = func(ctx context.Context, in runtime.RunInput) (*runtime.RunResult, error) { - got := rt.DrainUserInput() - if len(got) != 1 || got[0] != "queued before model call" { - t.Fatalf("runtime drain = %#v", got) - } - return &runtime.RunResult{Messages: []llm.Message{{Role: llm.RoleUser, Content: in.UserInput}}}, nil - } - - cmd := m.submit("start") - if cmd == nil { - t.Fatal("submit returned nil command") - } - if msg := cmd(); msg == nil { - t.Fatal("runner command returned nil message") - } -} - -func TestRunDoneStartsNextRunForUndrainedPendingInput(t *testing.T) { - rt := &runtime.Runtime{} - m := newModel(modelInit{Runtime: rt}) - m.state = stateRunning - m.queueInput("follow up") - m.textarea.SetValue("draft") - - _, cmd := m.Update(runDoneMsg{ - Result: &runtime.RunResult{Messages: []llm.Message{ - {Role: llm.RoleSystem, Content: "system"}, - {Role: llm.RoleUser, Content: "start"}, - {Role: llm.RoleAssistant, Content: "done"}, - }}, - }) - if cmd == nil { - t.Fatal("expected queued input to start a follow-up run") - } - if m.state != stateRunning { - t.Fatalf("state = %v, want running", m.state) - } - if m.pendingInputs != 0 { - t.Fatalf("pending inputs = %d", m.pendingInputs) - } - if got := m.textarea.Value(); got != "draft" { - t.Fatalf("textarea = %q", got) - } -} diff --git a/internal/tui/style.go b/internal/tui/style.go deleted file mode 100644 index e399440..0000000 --- a/internal/tui/style.go +++ /dev/null @@ -1,102 +0,0 @@ -package tui - -// style.go — lipgloss styles + theme constants used across the TUI. -// -// Color palette (256-color, falls back gracefully on dim terminals): -// accent — input border, prompt arrow -// tool — ⏺ tool_name lines -// toolResult — ⎿ result lines (dim) -// muted — status bar, hints -// warn — quit-armed indicator -// err — error messages - -import ( - "github.com/charmbracelet/lipgloss" -) - -var ( - colorAccent = lipgloss.Color("4") // blue - colorTool = lipgloss.Color("6") // cyan - colorToolResult = lipgloss.Color("8") // bright black - colorMuted = lipgloss.Color("8") // bright black (= dim gray) - colorWarn = lipgloss.Color("3") // yellow - colorErr = lipgloss.Color("1") // red - colorPromptArr = lipgloss.Color("4") -) - -var ( - styleStatusBar = lipgloss.NewStyle(). - Foreground(colorMuted) - - styleHelp = lipgloss.NewStyle(). - Foreground(colorMuted) - - styleToolName = lipgloss.NewStyle(). - Foreground(colorTool). - Bold(true) - - styleToolArgs = lipgloss.NewStyle(). - Foreground(colorTool) - - styleToolResult = lipgloss.NewStyle(). - Foreground(colorToolResult) - - styleErr = lipgloss.NewStyle(). - Foreground(colorErr). - Bold(true) - - styleWarn = lipgloss.NewStyle(). - Foreground(colorWarn) - - styleSpinner = lipgloss.NewStyle(). - Foreground(colorAccent) - - styleUserPrompt = lipgloss.NewStyle(). - Foreground(colorPromptArr). - Bold(true) - - // stylePromptArrow is the dim, simple "› " glyph the textarea - // uses as its prompt — no bold, no accent color, just a slight - // brightness so the cursor is the visual anchor. - stylePromptArrow = lipgloss.NewStyle(). - Foreground(colorMuted) - - stylePaletteActive = lipgloss.NewStyle(). - Foreground(colorAccent). - Bold(true) - - stylePaletteName = lipgloss.NewStyle() - - stylePaletteHelp = lipgloss.NewStyle(). - Foreground(colorMuted) - - // headerStyle is for selector / wizard titles inside the TUI. - headerStyle = lipgloss.NewStyle().Bold(true) - - styleMarkdownHeading = lipgloss.NewStyle(). - Bold(true) - - styleMarkdownSubheading = lipgloss.NewStyle(). - Bold(true) - - styleMarkdownBold = lipgloss.NewStyle(). - Bold(true) - - styleMarkdownInlineCode = lipgloss.NewStyle(). - Foreground(colorTool) - - styleMarkdownCodeBlock = lipgloss.NewStyle(). - Foreground(colorToolResult) - - styleMarkdownCodeLang = lipgloss.NewStyle(). - Foreground(colorMuted) - - styleMarkdownQuote = lipgloss.NewStyle(). - Foreground(colorMuted) - - styleMarkdownBullet = lipgloss.NewStyle(). - Foreground(colorMuted) - - styleMarkdownLink = lipgloss.NewStyle(). - Underline(true) -) diff --git a/internal/tui/tracer.go b/internal/tui/tracer.go deleted file mode 100644 index d7238ca..0000000 --- a/internal/tui/tracer.go +++ /dev/null @@ -1,49 +0,0 @@ -package tui - -// tracer.go — runtime.Tracer implementation that pushes events into a -// running Bubbletea Program via Send(). -// -// Bubbletea's Program is goroutine-safe for Send. The runtime calls -// these methods from whichever goroutine RunMsg() picked — usually a -// dedicated worker the TUI spawned. We do not block the runtime; Send -// is non-blocking, drops on closed program. - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/eight-acres-lab/openmelon/internal/llm" -) - -// programSender is the subset of *tea.Program we use. Tests can pass -// in a stub that records sent messages. -type programSender interface { - Send(msg tea.Msg) -} - -// programTracer fans runtime events into a Bubbletea program. -type programTracer struct { - prog programSender -} - -func newProgramTracer(p programSender) *programTracer { - return &programTracer{prog: p} -} - -func (t *programTracer) OnTurnStart(turn int) { - t.prog.Send(turnStartedMsg{Turn: turn}) -} - -func (t *programTracer) OnText(delta string) { - t.prog.Send(textDeltaMsg{Delta: delta}) -} - -func (t *programTracer) OnToolCall(call llm.ToolCall) { - t.prog.Send(toolCallMsg{Call: call}) -} - -func (t *programTracer) OnToolResult(call llm.ToolCall, content string, err error) { - t.prog.Send(toolResultMsg{Call: call, Content: content, Err: err}) -} - -func (t *programTracer) OnTurnEnd(turn int, finish llm.FinishReason, usage llm.Usage) { - t.prog.Send(turnEndedMsg{Turn: turn, Finish: finish, Usage: usage}) -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index ed30a1d..0000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,150 +0,0 @@ -package tui - -// tui.go — public entry point. Run() builds a Bubbletea Program around -// the Model in model.go, hooks the runtime's Tracer to it, and blocks -// until the user exits. - -import ( - "context" - "errors" - "fmt" - "os" - - tea "github.com/charmbracelet/bubbletea" - "github.com/eight-acres-lab/openmelon/internal/hooks" - "github.com/eight-acres-lab/openmelon/internal/llm" - "github.com/eight-acres-lab/openmelon/internal/projectx" - "github.com/eight-acres-lab/openmelon/internal/runtime" - "github.com/eight-acres-lab/openmelon/internal/session" - "github.com/eight-acres-lab/openmelon/internal/tools" -) - -// Options matches repl.Options where it makes sense; the TUI consumes -// them after the caller has wired up project + runtime + (optionally) -// the session-aware tool registry rebuild callback. -type Options struct { - Workdir string - Project *projectx.Project - Runtime *runtime.Runtime - WireSession func(sessionDir string) - SystemPrompt string - SessionIntent string - LLMTag string - ImageTag string - - // Provider info + hot-swap callbacks for the /model and - // /model-image selectors. - Provider string - ImageProvider string - LLMModel string - ImageModel string - RebuildLLM func(model string) (string, error) - RebuildImageModel func(provider, model string) (string, error) - BashMode projectx.BashPermissionMode - ReasoningEffort string - SaveSettings func(s projectx.Settings) error - - // ResumedFrom, when non-empty, is the session id this TUI is - // continuing. Recorded in the new session's meta.json so the - // chain of resumes is traceable. - ResumedFrom string - - // InitialHistory, if non-empty, pre-populates the conversation. - // First message is treated as the system prompt; everything else - // renders into the transcript and seeds m.history so the next - // user turn includes the prior context. - InitialHistory []llm.Message - - // InstallApprove, if non-nil, is called by tui.Run with the - // approval function the TUI provides. The caller installs it on - // tools.Env.Approve so the bash tool can ask for confirmation. - InstallApprove func(approve func(req tools.ApprovalRequest) tools.ApprovalDecision) -} - -// Run starts the TUI. Blocks until the user exits. -func Run(_ context.Context, opts Options) error { - if opts.Runtime == nil { - return errors.New("tui: Runtime is required") - } - if opts.Project == nil { - return errors.New("tui: Project is required") - } - - sess, err := session.NewResume(opts.Workdir, opts.Project.ID, opts.SessionIntent, opts.ResumedFrom) - if err != nil { - return fmt.Errorf("tui: session: %w", err) - } - defer sess.Close() - _ = sess.SetRuntimeInfo(opts.Provider, opts.LLMModel) - opts.Runtime.Hooks = hooks.ChainManagers(opts.Runtime.Hooks, sess.HookRecorder()) - - if opts.WireSession != nil { - opts.WireSession(sess.Dir) - } - - // Build the model with a runner closure. The runner is what the - // worker goroutine calls; it captures the runtime + tracer. - mInit := modelInit{ - Workdir: opts.Workdir, - Project: opts.Project, - Runtime: opts.Runtime, - SystemPrompt: opts.SystemPrompt, - Session: sess, - LLMTag: opts.LLMTag, - ImageTag: opts.ImageTag, - Provider: opts.Provider, - ImageProvider: opts.ImageProvider, - LLMModel: opts.LLMModel, - ImageModel: opts.ImageModel, - RebuildLLM: opts.RebuildLLM, - RebuildImageModel: opts.RebuildImageModel, - BashMode: opts.BashMode, - ReasoningEffort: opts.ReasoningEffort, - SaveSettings: opts.SaveSettings, - InitialHistory: opts.InitialHistory, - ResumedFrom: opts.ResumedFrom, - } - model := newModel(mInit) - - prog := tea.NewProgram( - model, - ) - - // Wire the Tracer now that we have a Program. - tracer := newProgramTracer(prog) - opts.Runtime.Tracer = tracer - - // Install the approval bridge. The bash tool calls this from the - // runtime worker goroutine; we send a tea.Msg into the program, - // the user picks one of the approval options in the modal, the - // model writes back to Reply, and we unblock here. - if opts.InstallApprove != nil { - opts.InstallApprove(func(req tools.ApprovalRequest) tools.ApprovalDecision { - reply := make(chan tools.ApprovalDecision, 1) - prog.Send(approvalRequestMsg{ - Tool: req.Tool, - Command: req.Command, - Description: req.Description, - Binary: req.Binary, - Reply: reply, - }) - return <-reply - }) - } - - // runner sends turn events through the tracer (which sends to the - // program). The function itself blocks until runtime.Run returns. - model.runner = func(ctx context.Context, in runtime.RunInput) (*runtime.RunResult, error) { - return opts.Runtime.Run(ctx, in) - } - - if _, err := prog.Run(); err != nil { - return fmt.Errorf("tui: %w", err) - } - // Resume hint — printed AFTER alt-screen restores so the user - // sees it in their normal shell scrollback. - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "session saved at "+sess.Dir) - fmt.Fprintln(os.Stderr, "to resume: openmelon resume "+sess.ID) - return nil -} diff --git a/internal/userconfig/credentials.go b/internal/userconfig/credentials.go deleted file mode 100644 index 7fbc988..0000000 --- a/internal/userconfig/credentials.go +++ /dev/null @@ -1,234 +0,0 @@ -package userconfig - -// credentials.go — ~/.openmelon/credentials.json (0600 perms). -// -// Lives separate from config.json so we can write it with restrictive -// permissions and exclude it from any sync / backup the user might be -// doing on their config dir. Same pattern as Codex CLI's auth.json and -// Claude Code's credentials store. -// -// Schema is intentionally simple: a string→string map of provider name -// (anthropic / openai / openrouter) to API key. Future OAuth tokens -// will get their own typed shape; for now we just store API keys. - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -// Credentials is the on-disk shape of ~/.openmelon/credentials.json. -type Credentials struct { - // APIKeys maps provider slug ("anthropic" / "openai" / "openrouter") - // to the user's API key for that provider. Missing entry = no key - // stored for that provider; the runtime falls back to the matching - // env var. - APIKeys map[string]string `json:"api_keys,omitempty"` -} - -// LoadCredentials reads ~/.openmelon/credentials.json. Missing file → -// empty Credentials (not an error — first run is fine). -func LoadCredentials() (*Credentials, error) { - home, err := Home() - if err != nil { - return nil, err - } - path := filepath.Join(home, "credentials.json") - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return &Credentials{APIKeys: map[string]string{}}, nil - } - return nil, fmt.Errorf("userconfig: read %s: %w", path, err) - } - var c Credentials - if err := json.Unmarshal(b, &c); err != nil { - return nil, fmt.Errorf("userconfig: parse %s: %w", path, err) - } - if c.APIKeys == nil { - c.APIKeys = map[string]string{} - } - return &c, nil -} - -// SaveCredentials writes ~/.openmelon/credentials.json with mode 0600. -func SaveCredentials(c *Credentials) error { - home, err := EnsureHome() - if err != nil { - return err - } - if c.APIKeys == nil { - c.APIKeys = map[string]string{} - } - path := filepath.Join(home, "credentials.json") - b, err := json.MarshalIndent(c, "", " ") - if err != nil { - return fmt.Errorf("userconfig: marshal credentials: %w", err) - } - // Atomic write with restrictive perms. - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, ".cred-") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - if err := os.Chmod(tmpPath, 0o600); err != nil { - tmp.Close() - return err - } - if _, err := tmp.Write(append(b, '\n')); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpPath, path) -} - -// SetAPIKey is a convenience wrapper for the common "set one key" path. -func SetAPIKey(provider, key string) error { - creds, err := LoadCredentials() - if err != nil { - return err - } - creds.APIKeys[provider] = key - return SaveCredentials(creds) -} - -// GetAPIKey returns the stored global key for provider, or "". -// -// Use ResolveAPIKey(workdir, provider) when you want the -// project-overrides-global semantics. -func GetAPIKey(provider string) string { - creds, err := LoadCredentials() - if err != nil { - return "" - } - return creds.APIKeys[provider] -} - -// projectCredentialsPath returns /.openmelon/credentials.json. -// We hard-code the dir name to avoid an import cycle with projectx — -// projectx.DirName is the same string. -func projectCredentialsPath(workdir string) string { - return filepath.Join(workdir, ".openmelon", "credentials.json") -} - -// LoadProjectCredentials reads /.openmelon/credentials.json. -// Missing file → empty Credentials (not an error). Used by the -// resolver below; callers can also use it directly to inspect. -func LoadProjectCredentials(workdir string) (*Credentials, error) { - path := projectCredentialsPath(workdir) - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return &Credentials{APIKeys: map[string]string{}}, nil - } - return nil, fmt.Errorf("userconfig: read %s: %w", path, err) - } - var c Credentials - if err := json.Unmarshal(b, &c); err != nil { - return nil, fmt.Errorf("userconfig: parse %s: %w", path, err) - } - if c.APIKeys == nil { - c.APIKeys = map[string]string{} - } - return &c, nil -} - -// SaveProjectCredentials writes the per-project credentials file with -// mode 0600. Atomic via temp + rename like SaveCredentials. -func SaveProjectCredentials(workdir string, c *Credentials) error { - if c.APIKeys == nil { - c.APIKeys = map[string]string{} - } - path := projectCredentialsPath(workdir) - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("userconfig: mkdir %s: %w", dir, err) - } - b, err := json.MarshalIndent(c, "", " ") - if err != nil { - return fmt.Errorf("userconfig: marshal project credentials: %w", err) - } - tmp, err := os.CreateTemp(dir, ".cred-") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - if err := os.Chmod(tmpPath, 0o600); err != nil { - tmp.Close() - return err - } - if _, err := tmp.Write(append(b, '\n')); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpPath, path) -} - -// SetProjectAPIKey is the project-scoped equivalent of SetAPIKey. -func SetProjectAPIKey(workdir, provider, key string) error { - creds, err := LoadProjectCredentials(workdir) - if err != nil { - return err - } - creds.APIKeys[provider] = key - return SaveProjectCredentials(workdir, creds) -} - -// UnsetProjectAPIKey removes a provider's key from the project -// credentials. No-op if the key wasn't set. -func UnsetProjectAPIKey(workdir, provider string) error { - creds, err := LoadProjectCredentials(workdir) - if err != nil { - return err - } - if _, ok := creds.APIKeys[provider]; !ok { - return nil - } - delete(creds.APIKeys, provider) - return SaveProjectCredentials(workdir, creds) -} - -// KeySource describes where a resolved API key came from. -type KeySource string - -const ( - // SourceProject means the key came from /.openmelon/credentials.json. - SourceProject KeySource = "project" - // SourceGlobal means it came from ~/.openmelon/credentials.json. - SourceGlobal KeySource = "global" - // SourceNone means no key was found at either scope. Caller may - // still fall back to env vars (llm.New does this internally). - SourceNone KeySource = "none" -) - -// ResolveAPIKey returns the API key for provider, with project-overrides- -// global semantics. Pass the project workdir; an empty workdir skips the -// project lookup. Returns ("", SourceNone) when neither scope has a key. -// -// Env vars are NOT consulted here — llm.New / imagegen.New do that as -// a final fallback when the passed-in key is empty. -func ResolveAPIKey(workdir, provider string) (string, KeySource) { - if workdir != "" { - if proj, err := LoadProjectCredentials(workdir); err == nil { - if k := proj.APIKeys[provider]; k != "" { - return k, SourceProject - } - } - } - if global, err := LoadCredentials(); err == nil { - if k := global.APIKeys[provider]; k != "" { - return k, SourceGlobal - } - } - return "", SourceNone -} diff --git a/internal/userconfig/credentials_test.go b/internal/userconfig/credentials_test.go deleted file mode 100644 index aaf8e98..0000000 --- a/internal/userconfig/credentials_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package userconfig - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadCredentialsMissingReturnsEmpty(t *testing.T) { - withTmpHome(t) - c, err := LoadCredentials() - if err != nil { - t.Fatalf("LoadCredentials: %v", err) - } - if len(c.APIKeys) != 0 { - t.Errorf("expected empty map, got %v", c.APIKeys) - } -} - -func TestSaveCredentialsRoundtrip(t *testing.T) { - withTmpHome(t) - in := &Credentials{APIKeys: map[string]string{ - "openrouter": "sk-or-test", - "openai": "sk-test", - }} - if err := SaveCredentials(in); err != nil { - t.Fatalf("SaveCredentials: %v", err) - } - out, err := LoadCredentials() - if err != nil { - t.Fatalf("LoadCredentials: %v", err) - } - if out.APIKeys["openrouter"] != "sk-or-test" || out.APIKeys["openai"] != "sk-test" { - t.Errorf("roundtrip mismatch: %+v", out.APIKeys) - } -} - -func TestSaveCredentialsWritesMode0600(t *testing.T) { - home := withTmpHome(t) - if err := SaveCredentials(&Credentials{APIKeys: map[string]string{"openrouter": "k"}}); err != nil { - t.Fatalf("SaveCredentials: %v", err) - } - st, err := os.Stat(filepath.Join(home, "credentials.json")) - if err != nil { - t.Fatalf("stat: %v", err) - } - mode := st.Mode().Perm() - if mode != 0o600 { - t.Errorf("expected mode 0600, got %o", mode) - } -} - -func TestSetAndGetAPIKey(t *testing.T) { - withTmpHome(t) - if err := SetAPIKey("openrouter", "sk-or-1"); err != nil { - t.Fatalf("SetAPIKey: %v", err) - } - if got := GetAPIKey("openrouter"); got != "sk-or-1" { - t.Errorf("GetAPIKey: got %q want sk-or-1", got) - } - if got := GetAPIKey("ghost"); got != "" { - t.Errorf("missing key should return empty, got %q", got) - } -} - -func TestProjectCredentialsRoundtripWithMode0600(t *testing.T) { - withTmpHome(t) - wd := t.TempDir() - if err := SetProjectAPIKey(wd, "openai", "sk-proj"); err != nil { - t.Fatalf("SetProjectAPIKey: %v", err) - } - st, err := os.Stat(filepath.Join(wd, ".openmelon", "credentials.json")) - if err != nil { - t.Fatalf("stat: %v", err) - } - if mode := st.Mode().Perm(); mode != 0o600 { - t.Errorf("expected mode 0600, got %o", mode) - } - out, err := LoadProjectCredentials(wd) - if err != nil { - t.Fatalf("LoadProjectCredentials: %v", err) - } - if out.APIKeys["openai"] != "sk-proj" { - t.Errorf("project key roundtrip: %v", out.APIKeys) - } -} - -func TestUnsetProjectAPIKey(t *testing.T) { - withTmpHome(t) - wd := t.TempDir() - _ = SetProjectAPIKey(wd, "openai", "sk-proj") - _ = SetProjectAPIKey(wd, "openrouter", "sk-or") - if err := UnsetProjectAPIKey(wd, "openai"); err != nil { - t.Fatalf("UnsetProjectAPIKey: %v", err) - } - out, _ := LoadProjectCredentials(wd) - if _, ok := out.APIKeys["openai"]; ok { - t.Errorf("openai not unset: %v", out.APIKeys) - } - if out.APIKeys["openrouter"] != "sk-or" { - t.Errorf("openrouter clobbered: %v", out.APIKeys) - } - // Unsetting an absent key is a no-op (no error). - if err := UnsetProjectAPIKey(wd, "ghost"); err != nil { - t.Errorf("unset of absent key should be no-op, got %v", err) - } -} - -func TestResolveAPIKey(t *testing.T) { - withTmpHome(t) - wd := t.TempDir() - - // Both scopes empty. - k, src := ResolveAPIKey(wd, "openrouter") - if k != "" || src != SourceNone { - t.Errorf("empty: got (%q, %s), want ('', none)", k, src) - } - - // Global only. - _ = SetAPIKey("openrouter", "sk-global") - k, src = ResolveAPIKey(wd, "openrouter") - if k != "sk-global" || src != SourceGlobal { - t.Errorf("global only: got (%q, %s)", k, src) - } - - // Both — project wins. - _ = SetProjectAPIKey(wd, "openrouter", "sk-proj") - k, src = ResolveAPIKey(wd, "openrouter") - if k != "sk-proj" || src != SourceProject { - t.Errorf("both: got (%q, %s), want (sk-proj, project)", k, src) - } - - // Different provider — falls back to global only. - _ = SetAPIKey("openai", "sk-openai-global") - k, src = ResolveAPIKey(wd, "openai") - if k != "sk-openai-global" || src != SourceGlobal { - t.Errorf("global fallback: got (%q, %s)", k, src) - } - - // Empty workdir → only checks global. - k, src = ResolveAPIKey("", "openrouter") - if k != "sk-global" || src != SourceGlobal { - t.Errorf("no workdir: got (%q, %s)", k, src) - } -} - -func TestIsTrustedExactAndSubdir(t *testing.T) { - c := &Config{TrustedDirs: []string{"/work/ai-talks"}} - if !c.IsTrusted("/work/ai-talks") { - t.Error("exact match should be trusted") - } - if !c.IsTrusted("/work/ai-talks/subdir") { - t.Error("subdir should be trusted") - } - if !c.IsTrusted("/work/ai-talks/deeply/nested") { - t.Error("deep subdir should be trusted") - } - if c.IsTrusted("/work/ai-talks-other") { - t.Error("prefix-only should NOT be trusted") - } - if c.IsTrusted("/elsewhere") { - t.Error("unrelated path should NOT be trusted") - } -} - -func TestAddTrustedIsIdempotent(t *testing.T) { - c := &Config{} - if !c.AddTrusted("/work/ai-talks") { - t.Error("first AddTrusted should return true") - } - if c.AddTrusted("/work/ai-talks") { - t.Error("second AddTrusted should return false") - } - if len(c.TrustedDirs) != 1 { - t.Errorf("expected 1 entry, got %d", len(c.TrustedDirs)) - } -} diff --git a/internal/userconfig/provider.go b/internal/userconfig/provider.go deleted file mode 100644 index 7937e04..0000000 --- a/internal/userconfig/provider.go +++ /dev/null @@ -1,105 +0,0 @@ -package userconfig - -import ( - "os" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -// ResolvedProvider is the effective connection config for one provider. -type ResolvedProvider struct { - Provider string - APIKey string - BaseURL string - KeySource string - URLSource string -} - -// ResolveProvider resolves provider API key + base URL from project and -// global config, while preserving the existing credentials/env fallback -// behavior. -// -// Precedence: -// - /.openmelon/project.json:providers. -// - ~/.openmelon/config.json:providers. -// - /.openmelon/credentials.json api_keys -// - ~/.openmelon/credentials.json api_keys -// - environment variables (OPENAI_API_KEY, OPENAI_BASE_URL, etc.) -// -// Empty BaseURL means the provider constructor should use its built-in -// default. -func ResolveProvider(workdir, provider string) ResolvedProvider { - out := ResolvedProvider{Provider: provider} - - if workdir != "" { - if p, err := projectx.Load(workdir); err == nil { - if pc, ok := p.Providers[provider]; ok { - if pc.APIKey != "" { - out.APIKey = pc.APIKey - out.KeySource = "project.config" - } - if pc.BaseURL != "" { - out.BaseURL = pc.BaseURL - out.URLSource = "project.config" - } - } - } - } - - if cfg, err := LoadConfig(); err == nil && cfg != nil { - if pc, ok := cfg.Providers[provider]; ok { - if out.APIKey == "" && pc.APIKey != "" { - out.APIKey = pc.APIKey - out.KeySource = "global.config" - } - if out.BaseURL == "" && pc.BaseURL != "" { - out.BaseURL = pc.BaseURL - out.URLSource = "global.config" - } - } - } - - if out.APIKey == "" { - if k, src := ResolveAPIKey(workdir, provider); k != "" { - out.APIKey = k - out.KeySource = string(src) + ".credentials" - } - } - - if out.APIKey == "" { - if k := os.Getenv(apiKeyEnv(provider)); k != "" { - out.APIKey = k - out.KeySource = "env" - } - } - if out.BaseURL == "" { - if u := os.Getenv(baseURLEnv(provider)); u != "" { - out.BaseURL = u - out.URLSource = "env" - } - } - - return out -} - -func apiKeyEnv(provider string) string { - switch provider { - case "anthropic": - return "ANTHROPIC_API_KEY" - case "openrouter": - return "OPENROUTER_API_KEY" - default: - return "OPENAI_API_KEY" - } -} - -func baseURLEnv(provider string) string { - switch provider { - case "anthropic": - return "ANTHROPIC_BASE_URL" - case "openrouter": - return "OPENROUTER_BASE_URL" - default: - return "OPENAI_BASE_URL" - } -} diff --git a/internal/userconfig/provider_test.go b/internal/userconfig/provider_test.go deleted file mode 100644 index b8fa354..0000000 --- a/internal/userconfig/provider_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package userconfig - -import ( - "os" - "path/filepath" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/projectx" -) - -func TestResolveProviderPrecedence(t *testing.T) { - home := t.TempDir() - t.Setenv("OPENMELON_HOME", home) - t.Setenv("OPENAI_API_KEY", "env-key") - t.Setenv("OPENAI_BASE_URL", "https://env.example.com") - - if err := SaveConfig(&Config{ - Providers: map[string]ProviderConfig{ - "openai": {APIKey: "global-key", BaseURL: "https://global.example.com"}, - }, - }); err != nil { - t.Fatalf("SaveConfig: %v", err) - } - - wd := t.TempDir() - p, err := projectx.Init(wd, "demo", "Demo") - if err != nil { - t.Fatalf("project init: %v", err) - } - p.Providers = map[string]projectx.ProviderConfig{ - "openai": {APIKey: "project-key", BaseURL: "https://project.example.com"}, - } - if err := projectx.Save(wd, p); err != nil { - t.Fatalf("project save: %v", err) - } - - got := ResolveProvider(wd, "openai") - if got.APIKey != "project-key" || got.BaseURL != "https://project.example.com" { - t.Fatalf("project config should win: %+v", got) - } - - p.Providers = nil - if err := projectx.Save(wd, p); err != nil { - t.Fatalf("project save: %v", err) - } - got = ResolveProvider(wd, "openai") - if got.APIKey != "global-key" || got.BaseURL != "https://global.example.com" { - t.Fatalf("global config should win after project removed: %+v", got) - } - - if err := os.Remove(filepath.Join(home, "config.json")); err != nil { - t.Fatal(err) - } - got = ResolveProvider(wd, "openai") - if got.APIKey != "env-key" || got.BaseURL != "https://env.example.com" { - t.Fatalf("env should win after config removed: %+v", got) - } -} diff --git a/internal/userconfig/userconfig.go b/internal/userconfig/userconfig.go deleted file mode 100644 index 85abf5f..0000000 --- a/internal/userconfig/userconfig.go +++ /dev/null @@ -1,377 +0,0 @@ -// Package userconfig is openmelon's global per-user state. -// -// Layout under $OPENMELON_HOME (default ~/.openmelon): -// -// config.json defaults: API keys (or env-passthrough), default models, current project -// projects.json registry of known projects: id → workdir -// cache/ downloaded artifacts (future) -// -// The config file is intentionally JSON, not YAML — the rest of the -// runtime is zero-dep stdlib and we keep it that way. Skillplus packages -// are still YAML; we read those by shelling to the `skillplus` CLI, not -// by parsing them in Go. -package userconfig - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "time" -) - -// Config is the on-disk shape of ~/.openmelon/config.json. -// -// All fields are optional. Empty values mean "fall back to env / vendor -// default". The CLI never writes API keys here — keys live in -// credentials.json (0600) instead. We persist only model + provider -// preferences and the trusted-directories list. -type Config struct { - // CurrentProject is the project ID used when no -C / cwd-discovered - // project applies. Empty means "no global default". - CurrentProject string `json:"current_project,omitempty"` - - // Defaults are the agent defaults applied when no per-project or - // per-invocation override is given. - Defaults Defaults `json:"defaults,omitempty"` - - // Providers holds optional global provider configuration. Values here - // are lower precedence than project.json:providers but higher than - // credentials.json / environment variables. - Providers map[string]ProviderConfig `json:"providers,omitempty"` - - // TrustedDirs are absolute paths the user has explicitly trusted. - // A directory is "trusted" if it exactly matches an entry, or if - // it's a subdirectory of one. The TUI prompts on every launch - // when cwd is not trusted. - TrustedDirs []string `json:"trusted_dirs,omitempty"` -} - -// IsTrusted returns true when path equals or is a subdirectory of any -// entry in TrustedDirs. Both sides are absolute-pathed first. -func (c *Config) IsTrusted(path string) bool { - abs, err := filepath.Abs(path) - if err != nil { - return false - } - absReal := evalSymlinkBestEffort(abs) - for _, t := range c.TrustedDirs { - tAbs, err := filepath.Abs(t) - if err != nil { - continue - } - tReal := evalSymlinkBestEffort(tAbs) - if sameOrSubdir(tAbs, abs) || sameOrSubdir(tReal, absReal) || sameOrSubdir(tAbs, absReal) || sameOrSubdir(tReal, abs) { - return true - } - } - return false -} - -func sameOrSubdir(parent, child string) bool { - if parent == "" || child == "" { - return false - } - if child == parent { - return true - } - // Subdir check — make sure we're not just matching a prefix - // (e.g. /work matching /workshop). - rel, err := filepath.Rel(parent, child) - return err == nil && rel != ".." && !strings.HasPrefix(rel, "../") && rel != "" -} - -func evalSymlinkBestEffort(path string) string { - real, err := filepath.EvalSymlinks(path) - if err != nil { - return path - } - return real -} - -// AddTrusted adds path (absolute-d) to TrustedDirs if not already -// present. Returns true if a new entry was added. -func (c *Config) AddTrusted(path string) bool { - abs, err := filepath.Abs(path) - if err != nil { - return false - } - for _, t := range c.TrustedDirs { - if t == abs { - return false - } - } - c.TrustedDirs = append(c.TrustedDirs, abs) - return true -} - -// Defaults are the agent defaults used in the absence of any override. -type Defaults struct { - // LLMProvider is one of "auto", "anthropic", "openai", "openrouter". - LLMProvider string `json:"llm_provider,omitempty"` - // LLMModel is a vendor-specific model id, e.g. "x-ai/grok-4". - LLMModel string `json:"llm_model,omitempty"` - // ImageProvider is one of "openai", "openrouter". - ImageProvider string `json:"image_provider,omitempty"` - // ImageModel is a vendor-specific model id, e.g. - // "google/gemini-2.5-flash-image". - ImageModel string `json:"image_model,omitempty"` - // VisionModel is the model id used by add_character / add_reference - // to auto-write a description for an image. Empty → reuse LLMModel - // if it's vision-capable, else require explicit. - VisionModel string `json:"vision_model,omitempty"` - // Locale is the default skill compile locale. - Locale string `json:"locale,omitempty"` - // ReasoningEffort is the default thinking-depth hint for providers - // that support it: minimal, low, medium, high, or xhigh. - ReasoningEffort string `json:"reasoning_effort,omitempty"` -} - -// ProviderConfig holds optional API connection settings for a provider. -// It can appear in ~/.openmelon/config.json or in -// /.openmelon/project.json. Project values win over global. -// -// api_key is supported here for users who want one config file, but the -// safer default remains credentials.json (0600). -type ProviderConfig struct { - APIKey string `json:"api_key,omitempty"` - BaseURL string `json:"base_url,omitempty"` -} - -// ProjectEntry is one row in projects.json — the global registry that -// maps a project ID (slug) to its workdir on disk. -type ProjectEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Workdir string `json:"workdir"` - CreatedAt time.Time `json:"created_at"` - LastUsedAt time.Time `json:"last_used_at,omitempty"` -} - -// Projects is the on-disk shape of ~/.openmelon/projects.json. -type Projects struct { - Entries []ProjectEntry `json:"entries"` -} - -// ErrNoCurrentProject is returned by ResolveCurrent when there is no -// CWD-discovered project AND no global default. -var ErrNoCurrentProject = errors.New("userconfig: no current project — run `openmelon init` in a project directory or `openmelon project use `") - -// ErrProjectNotFound is returned by Lookup when the given id is not -// registered in projects.json. -var ErrProjectNotFound = errors.New("userconfig: project not found") - -// Home returns the resolved $OPENMELON_HOME path. Default ~/.openmelon. -func Home() (string, error) { - if h := os.Getenv("OPENMELON_HOME"); h != "" { - return h, nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("userconfig: resolve user home: %w", err) - } - return filepath.Join(home, ".openmelon"), nil -} - -// EnsureHome creates $OPENMELON_HOME (and cache/) on first use. -func EnsureHome() (string, error) { - home, err := Home() - if err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Join(home, "cache"), 0o755); err != nil { - return "", fmt.Errorf("userconfig: create %s: %w", home, err) - } - return home, nil -} - -// LoadConfig reads ~/.openmelon/config.json. Missing file → empty Config -// (not an error — first run is fine). -func LoadConfig() (*Config, error) { - home, err := Home() - if err != nil { - return nil, err - } - path := filepath.Join(home, "config.json") - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return &Config{}, nil - } - return nil, fmt.Errorf("userconfig: read %s: %w", path, err) - } - var c Config - if err := json.Unmarshal(b, &c); err != nil { - return nil, fmt.Errorf("userconfig: parse %s: %w", path, err) - } - return &c, nil -} - -// SaveConfig writes ~/.openmelon/config.json (creating $OPENMELON_HOME -// if needed). -func SaveConfig(c *Config) error { - home, err := EnsureHome() - if err != nil { - return err - } - path := filepath.Join(home, "config.json") - b, err := json.MarshalIndent(c, "", " ") - if err != nil { - return fmt.Errorf("userconfig: marshal config: %w", err) - } - return atomicWrite(path, append(b, '\n')) -} - -// LoadProjects reads ~/.openmelon/projects.json. Missing file → empty -// list (not an error). -func LoadProjects() (*Projects, error) { - home, err := Home() - if err != nil { - return nil, err - } - path := filepath.Join(home, "projects.json") - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return &Projects{Entries: []ProjectEntry{}}, nil - } - return nil, fmt.Errorf("userconfig: read %s: %w", path, err) - } - var p Projects - if err := json.Unmarshal(b, &p); err != nil { - return nil, fmt.Errorf("userconfig: parse %s: %w", path, err) - } - if p.Entries == nil { - p.Entries = []ProjectEntry{} - } - return &p, nil -} - -// SaveProjects writes ~/.openmelon/projects.json with entries sorted by -// id for stable diffs. -func SaveProjects(p *Projects) error { - home, err := EnsureHome() - if err != nil { - return err - } - if p.Entries == nil { - p.Entries = []ProjectEntry{} - } - sort.Slice(p.Entries, func(i, j int) bool { return p.Entries[i].ID < p.Entries[j].ID }) - path := filepath.Join(home, "projects.json") - b, err := json.MarshalIndent(p, "", " ") - if err != nil { - return fmt.Errorf("userconfig: marshal projects: %w", err) - } - return atomicWrite(path, append(b, '\n')) -} - -// Register adds (or updates) a project entry. Idempotent: if id already -// exists, the workdir + name are overwritten and last_used_at is bumped. -func Register(id, name, workdir string) error { - abs, err := filepath.Abs(workdir) - if err != nil { - return fmt.Errorf("userconfig: resolve workdir: %w", err) - } - projects, err := LoadProjects() - if err != nil { - return err - } - now := time.Now().UTC() - found := false - for i := range projects.Entries { - if projects.Entries[i].ID == id { - projects.Entries[i].Name = name - projects.Entries[i].Workdir = abs - projects.Entries[i].LastUsedAt = now - found = true - break - } - } - if !found { - projects.Entries = append(projects.Entries, ProjectEntry{ - ID: id, - Name: name, - Workdir: abs, - CreatedAt: now, - LastUsedAt: now, - }) - } - return SaveProjects(projects) -} - -// Lookup returns the registry entry for id, or ErrProjectNotFound. -func Lookup(id string) (*ProjectEntry, error) { - projects, err := LoadProjects() - if err != nil { - return nil, err - } - for i := range projects.Entries { - if projects.Entries[i].ID == id { - e := projects.Entries[i] - return &e, nil - } - } - return nil, fmt.Errorf("%w: %q", ErrProjectNotFound, id) -} - -// SetCurrent updates config.current_project. Errors if the id is not -// registered. -func SetCurrent(id string) error { - if _, err := Lookup(id); err != nil { - return err - } - c, err := LoadConfig() - if err != nil { - return err - } - c.CurrentProject = id - return SaveConfig(c) -} - -// MarkUsed bumps last_used_at on the given id (best-effort; missing id -// is a no-op). -func MarkUsed(id string) error { - projects, err := LoadProjects() - if err != nil { - return err - } - changed := false - for i := range projects.Entries { - if projects.Entries[i].ID == id { - projects.Entries[i].LastUsedAt = time.Now().UTC() - changed = true - break - } - } - if !changed { - return nil - } - return SaveProjects(projects) -} - -// atomicWrite writes to path via a temp file + rename so partial writes -// can never corrupt config. -func atomicWrite(path string, data []byte) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - tmp, err := os.CreateTemp(dir, ".tmp-") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpPath, path) -} diff --git a/internal/userconfig/userconfig_test.go b/internal/userconfig/userconfig_test.go deleted file mode 100644 index 3e3d9ac..0000000 --- a/internal/userconfig/userconfig_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package userconfig - -import ( - "os" - "path/filepath" - "testing" -) - -// withTmpHome points $OPENMELON_HOME at a fresh tmpdir for the test, so -// no test ever touches the real ~/.openmelon. -func withTmpHome(t *testing.T) string { - t.Helper() - dir := t.TempDir() - t.Setenv("OPENMELON_HOME", dir) - return dir -} - -func TestEnsureHomeCreatesDir(t *testing.T) { - home := withTmpHome(t) - got, err := EnsureHome() - if err != nil { - t.Fatalf("EnsureHome: %v", err) - } - if got != home { - t.Errorf("home mismatch: got %q want %q", got, home) - } - if _, err := os.Stat(filepath.Join(home, "cache")); err != nil { - t.Errorf("cache dir not created: %v", err) - } -} - -func TestLoadConfigMissingReturnsEmpty(t *testing.T) { - withTmpHome(t) - c, err := LoadConfig() - if err != nil { - t.Fatalf("LoadConfig: %v", err) - } - if c.CurrentProject != "" || c.Defaults.LLMProvider != "" { - t.Errorf("expected empty Config, got %+v", c) - } -} - -func TestSaveAndLoadConfigRoundtrip(t *testing.T) { - withTmpHome(t) - in := &Config{ - CurrentProject: "ai-talks", - Defaults: Defaults{ - LLMProvider: "openrouter", - LLMModel: "x-ai/grok-4", - ImageProvider: "openrouter", - ImageModel: "google/gemini-2.5-flash-image", - Locale: "zh-CN", - }, - } - if err := SaveConfig(in); err != nil { - t.Fatalf("SaveConfig: %v", err) - } - out, err := LoadConfig() - if err != nil { - t.Fatalf("LoadConfig: %v", err) - } - if out.CurrentProject != in.CurrentProject { - t.Errorf("current_project: got %q want %q", out.CurrentProject, in.CurrentProject) - } - if out.Defaults != in.Defaults { - t.Errorf("defaults mismatch: got %+v want %+v", out.Defaults, in.Defaults) - } -} - -func TestConfigTrustHandlesSymlinkedPaths(t *testing.T) { - root := t.TempDir() - target := filepath.Join(root, "target") - if err := os.MkdirAll(filepath.Join(target, "child"), 0o755); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, "link") - if err := os.Symlink(target, link); err != nil { - t.Skipf("symlink unsupported: %v", err) - } - cfg := &Config{TrustedDirs: []string{link}} - if !cfg.IsTrusted(filepath.Join(target, "child")) { - t.Fatal("expected symlink-equivalent target child to be trusted") - } -} - -func TestRegisterAndLookup(t *testing.T) { - withTmpHome(t) - wd := t.TempDir() - if err := Register("ai-talks", "AI Talks", wd); err != nil { - t.Fatalf("Register: %v", err) - } - e, err := Lookup("ai-talks") - if err != nil { - t.Fatalf("Lookup: %v", err) - } - if e.Name != "AI Talks" { - t.Errorf("name: got %q want %q", e.Name, "AI Talks") - } - abs, _ := filepath.Abs(wd) - if e.Workdir != abs { - t.Errorf("workdir: got %q want %q", e.Workdir, abs) - } - if e.CreatedAt.IsZero() { - t.Error("created_at not set") - } -} - -func TestRegisterIsIdempotentAndUpdatesOnReregister(t *testing.T) { - withTmpHome(t) - wd1 := t.TempDir() - wd2 := t.TempDir() - if err := Register("ai-talks", "AI Talks", wd1); err != nil { - t.Fatalf("Register #1: %v", err) - } - created, err := Lookup("ai-talks") - if err != nil { - t.Fatalf("Lookup #1: %v", err) - } - if err := Register("ai-talks", "AI Talks (renamed)", wd2); err != nil { - t.Fatalf("Register #2: %v", err) - } - updated, err := Lookup("ai-talks") - if err != nil { - t.Fatalf("Lookup #2: %v", err) - } - if updated.Name != "AI Talks (renamed)" { - t.Errorf("name not updated: %q", updated.Name) - } - abs2, _ := filepath.Abs(wd2) - if updated.Workdir != abs2 { - t.Errorf("workdir not updated: got %q want %q", updated.Workdir, abs2) - } - // The original CreatedAt should survive the re-register. - if !updated.CreatedAt.Equal(created.CreatedAt) { - t.Errorf("created_at changed across re-register: %v vs %v", updated.CreatedAt, created.CreatedAt) - } - - projects, err := LoadProjects() - if err != nil { - t.Fatalf("LoadProjects: %v", err) - } - if len(projects.Entries) != 1 { - t.Errorf("expected 1 entry after idempotent re-register, got %d", len(projects.Entries)) - } -} - -func TestLookupMissingReturnsErrProjectNotFound(t *testing.T) { - withTmpHome(t) - _, err := Lookup("does-not-exist") - if err == nil { - t.Fatal("expected error") - } -} - -func TestSetCurrentRequiresRegistration(t *testing.T) { - withTmpHome(t) - if err := SetCurrent("ghost"); err == nil { - t.Fatal("expected error setting unregistered project as current") - } -} - -func TestSetCurrentPersistsToConfig(t *testing.T) { - withTmpHome(t) - wd := t.TempDir() - if err := Register("ai-talks", "AI Talks", wd); err != nil { - t.Fatalf("Register: %v", err) - } - if err := SetCurrent("ai-talks"); err != nil { - t.Fatalf("SetCurrent: %v", err) - } - c, err := LoadConfig() - if err != nil { - t.Fatalf("LoadConfig: %v", err) - } - if c.CurrentProject != "ai-talks" { - t.Errorf("current_project: got %q want ai-talks", c.CurrentProject) - } -} - -func TestProjectsPersistedSortedById(t *testing.T) { - withTmpHome(t) - for _, id := range []string{"zebra", "alpha", "bravo"} { - if err := Register(id, id, t.TempDir()); err != nil { - t.Fatalf("Register %s: %v", id, err) - } - } - projects, err := LoadProjects() - if err != nil { - t.Fatalf("LoadProjects: %v", err) - } - want := []string{"alpha", "bravo", "zebra"} - if len(projects.Entries) != len(want) { - t.Fatalf("entries len %d, want %d", len(projects.Entries), len(want)) - } - for i, id := range want { - if projects.Entries[i].ID != id { - t.Errorf("[%d] id: got %q want %q", i, projects.Entries[i].ID, id) - } - } -} diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index f4acff8..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,12 +0,0 @@ -// Package version exposes the build version of openmelon. -// -// The default value is the development sentinel "dev". Release builds -// override it with -ldflags: -// -// go build -ldflags "-X github.com/eight-acres-lab/openmelon/internal/version.Version=v0.2.0" ./cmd/openmelon -// -// Release scripts read the tag from `git describe --tags` and pass it in. -package version - -// Version is set at build time. "dev" indicates an untagged build. -var Version = "dev" diff --git a/internal/workflow/definition.go b/internal/workflow/definition.go deleted file mode 100644 index b307d24..0000000 --- a/internal/workflow/definition.go +++ /dev/null @@ -1,57 +0,0 @@ -package workflow - -import ( - "encoding/json" - "fmt" - "os" -) - -// WorkflowDefinition describes a multi-stage content production workflow loaded from project.json. -type WorkflowDefinition struct { - ID string `json:"id"` - Name string `json:"name"` - Vertical string `json:"vertical"` - Stages []StageDefinition `json:"stages"` -} - -// StageDefinition describes a single stage within a WorkflowDefinition. -type StageDefinition struct { - Stage Stage `json:"stage"` - SkillPlusPackage string `json:"skillplus_package"` - CompileTarget string `json:"compile_target"` - ModelProfile string `json:"model_profile"` - ArtifactType string `json:"artifact_type"` - Locale string `json:"locale,omitempty"` - Vars map[string]string `json:"vars,omitempty"` -} - -// projectWorkflowsFile is a minimal struct used only for extracting the "workflows" key. -type projectWorkflowsFile struct { - Workflows map[string]*WorkflowDefinition `json:"workflows"` -} - -// LoadWorkflows reads the project JSON file at path and returns a map of WorkflowDefinitions. -// Each WorkflowDefinition has its ID field populated from the map key if not already set. -func LoadWorkflows(path string) (map[string]*WorkflowDefinition, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("workflow.LoadWorkflows: read %q: %w", path, err) - } - - var pf projectWorkflowsFile - if err := json.Unmarshal(data, &pf); err != nil { - return nil, fmt.Errorf("workflow.LoadWorkflows: parse %q: %w", path, err) - } - - if len(pf.Workflows) == 0 { - return nil, fmt.Errorf("workflow.LoadWorkflows: no workflows found in %q", path) - } - - for key, wf := range pf.Workflows { - if wf.ID == "" { - wf.ID = key - } - } - - return pf.Workflows, nil -} diff --git a/internal/workflow/definition_test.go b/internal/workflow/definition_test.go deleted file mode 100644 index 29ffb0c..0000000 --- a/internal/workflow/definition_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package workflow - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// minimalProjectJSON is a self-contained project.json used by these unit tests. -// It avoids any dependency on the examples/ directory. -const minimalProjectJSON = `{ - "id": "test-project", - "name": "Test Project", - "platform": "xiaohongshu", - "audience": "testers", - "persona": "test persona", - "workflows": { - "test_flow": { - "id": "test_flow", - "name": "Test Flow", - "vertical": "food", - "stages": [ - { - "stage": "visual_concretization", - "skillplus_package": "some/package.skillplus", - "compile_target": "openmelon", - "model_profile": "image_generator", - "artifact_type": "image_prompt", - "locale": "zh-CN", - "vars": {"realism_level": "high"} - } - ] - } - } -}` - -func writeProjectFile(t *testing.T, dir, content string) string { - t.Helper() - path := filepath.Join(dir, "project.json") - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - return path -} - -func TestLoadWorkflows_validJSON(t *testing.T) { - dir := t.TempDir() - path := writeProjectFile(t, dir, minimalProjectJSON) - - wfs, err := LoadWorkflows(path) - if err != nil { - t.Fatalf("LoadWorkflows error: %v", err) - } - if len(wfs) != 1 { - t.Fatalf("expected 1 workflow, got %d", len(wfs)) - } - wf, ok := wfs["test_flow"] - if !ok { - t.Fatal("expected key 'test_flow' in workflows map") - } - if wf.ID != "test_flow" { - t.Errorf("ID = %q, want %q", wf.ID, "test_flow") - } - if wf.Vertical != "food" { - t.Errorf("Vertical = %q, want %q", wf.Vertical, "food") - } - if len(wf.Stages) != 1 { - t.Fatalf("stages len = %d, want 1", len(wf.Stages)) - } - if wf.Stages[0].Stage != StageVisualConcretization { - t.Errorf("stage[0] = %q, want %q", wf.Stages[0].Stage, StageVisualConcretization) - } -} - -func TestLoadWorkflows_fileNotFound(t *testing.T) { - _, err := LoadWorkflows("/nonexistent/path/project.json") - if err == nil { - t.Fatal("expected error for missing file, got nil") - } -} - -func TestLoadWorkflows_noWorkflows(t *testing.T) { - dir := t.TempDir() - // Valid project JSON but no workflows key. - noWf := `{"id":"x","name":"x","platform":"x"}` - path := writeProjectFile(t, dir, noWf) - - _, err := LoadWorkflows(path) - if err == nil { - t.Fatal("expected error for missing workflows, got nil") - } -} - -func TestLoadWorkflows_idInferredFromMapKey(t *testing.T) { - // When workflow JSON has no "id" field, it should be set from the map key. - noIDJSON := `{ - "id": "proj", "name": "proj", "platform": "x", - "workflows": { - "inferred_id": { - "name": "No ID Flow", - "vertical": "food", - "stages": [] - } - } - }` - dir := t.TempDir() - path := writeProjectFile(t, dir, noIDJSON) - - wfs, err := LoadWorkflows(path) - if err != nil { - t.Fatalf("LoadWorkflows error: %v", err) - } - wf := wfs["inferred_id"] - if wf == nil { - t.Fatal("workflow not found") - } - if wf.ID != "inferred_id" { - t.Errorf("ID = %q, want %q", wf.ID, "inferred_id") - } -} - -// TestLoadWorkflows_stagesVarsPreserved verifies that vars map entries survive round-trip. -func TestLoadWorkflows_stagesVarsPreserved(t *testing.T) { - dir := t.TempDir() - path := writeProjectFile(t, dir, minimalProjectJSON) - - wfs, _ := LoadWorkflows(path) - stage := wfs["test_flow"].Stages[0] - - if stage.Vars["realism_level"] != "high" { - t.Errorf("vars['realism_level'] = %q, want %q", stage.Vars["realism_level"], "high") - } -} - -// TestWorkflowDefinition_jsonRoundTrip verifies JSON serialisation of WorkflowDefinition. -func TestWorkflowDefinition_jsonRoundTrip(t *testing.T) { - wf := &WorkflowDefinition{ - ID: "round_trip", - Name: "Round Trip", - Vertical: "test", - Stages: []StageDefinition{ - { - Stage: StageCopywriting, - SkillPlusPackage: "pkg.skillplus", - CompileTarget: "openmelon", - ModelProfile: "gpt-4", - ArtifactType: "copy_draft", - }, - }, - } - - data, err := json.Marshal(wf) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var out WorkflowDefinition - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.ID != wf.ID || out.Stages[0].Stage != wf.Stages[0].Stage { - t.Errorf("round-trip mismatch: got %+v", out) - } -} diff --git a/internal/workflow/engine.go b/internal/workflow/engine.go deleted file mode 100644 index c83c95f..0000000 --- a/internal/workflow/engine.go +++ /dev/null @@ -1,193 +0,0 @@ -package workflow - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "time" - - "github.com/eight-acres-lab/openmelon/internal/artifacts" - "github.com/eight-acres-lab/openmelon/internal/generation" - "github.com/eight-acres-lab/openmelon/internal/project" - "github.com/eight-acres-lab/openmelon/internal/provenance" - "github.com/eight-acres-lab/openmelon/internal/skillplus" -) - -// RunRequest holds all parameters needed to execute a workflow. -type RunRequest struct { - // Project is the loaded project for context variables. - Project *project.Project - // WorkflowDef is the workflow definition to execute. - WorkflowDef *WorkflowDefinition - // Intent is the operator's free-text intent passed to each compile stage as a variable. - Intent string - // ArtifactDir is the directory where artifact files are written. - ArtifactDir string - // CompilerPath is the PYTHONPATH for the Skill-Plus Python compiler. - CompilerPath string - // ProjectDir is the directory containing the project.json file, used to - // resolve relative skillplus_package paths in stage definitions. - ProjectDir string - // ProvenancePath is the path to the append-only JSONL provenance log. - ProvenancePath string - // Compiler is the Skill-Plus compiler adapter (optional override, created from CompilerPath if nil). - Compiler *skillplus.Compiler - // Provider is the generation provider used for model calls. - Provider generation.Provider - // Generate controls whether the generation step is executed (set false for compile-only dry-runs). - Generate bool -} - -// StageResult is the output of a single workflow stage execution. -type StageResult struct { - Stage Stage - Artifact *artifacts.Artifact -} - -// Engine runs a workflow definition stage by stage. -type Engine struct{} - -// Run executes the workflow defined in req.WorkflowDef stage by stage. -// For each stage it: -// 1. Compiles the Skill-Plus package into a prompt. -// 2. Optionally calls req.Provider.Generate to produce artifact content. -// 3. Assigns a StableID and writes the artifact to req.ArtifactDir. -// 4. Appends a provenance record to req.ProvenancePath. -// -// Run returns all stage results or an error on the first failure. -func (e *Engine) Run(ctx context.Context, req *RunRequest) ([]*StageResult, error) { - compiler := req.Compiler - if compiler == nil { - compiler = &skillplus.Compiler{CompilerPath: req.CompilerPath} - } - - var results []*StageResult - - for _, stage := range req.WorkflowDef.Stages { - select { - case <-ctx.Done(): - return results, ctx.Err() - default: - } - - // -- Step 1: Resolve SkillPlus package path relative to the project file's dir -- - pkgPath := stage.SkillPlusPackage - if req.ProjectDir != "" && !filepath.IsAbs(pkgPath) { - pkgPath = filepath.Join(req.ProjectDir, pkgPath) - } - - // -- Step 2: Build compile vars including intent and project context -- - vars := make(map[string]string) - for k, v := range stage.Vars { - vars[k] = v - } - if req.Intent != "" { - vars["intent"] = req.Intent - } - if req.Project.Audience != "" { - vars["audience"] = req.Project.Audience - } - if req.Project.Persona != "" { - vars["persona"] = req.Project.Persona - } - - compileReq := &skillplus.CompileRequest{ - PackagePath: pkgPath, - Target: stage.CompileTarget, - ModelProfile: stage.ModelProfile, - Locale: stage.Locale, - Vars: vars, - } - - compiled, err := compiler.Compile(ctx, compileReq) - if err != nil { - return results, fmt.Errorf("engine: compile stage %q: %w", stage.Stage, err) - } - - // -- Step 3: Optionally generate artifact content -- - content := compiled.Prompt - var trace *generation.Trace - if req.Generate && req.Provider != nil { - genReq := &generation.Request{ - ArtifactType: stage.ArtifactType, - Prompt: compiled.Prompt, - Model: compiled.ModelProfile, - Params: compiled.RuntimeVars, - Intent: req.Intent, - } - content, trace, err = req.Provider.Generate(ctx, genReq) - if err != nil { - return results, fmt.Errorf("engine: generate stage %q: %w", stage.Stage, err) - } - } - - // -- Step 4: Build stable artifact ID and provenance record -- - artifactID := artifacts.StableID( - req.Project.ID, - req.WorkflowDef.ID, - string(stage.Stage), - compiled.PackageID, - ) - - now := time.Now().UTC().Format(time.RFC3339) - recModel := compiled.ModelProfile - if trace != nil && trace.Model != "" { - recModel = trace.Model - } - rec := &provenance.Record{ - ArtifactID: artifactID, - ProjectID: req.Project.ID, - WorkflowID: req.WorkflowDef.ID, - Stage: string(stage.Stage), - SkillPackage: pkgPath, - CompiledTarget: stage.CompileTarget, - Model: recModel, - PromptHash: artifacts.StableID(compiled.Prompt), - Timestamp: now, - } - if trace != nil { - params := map[string]string{ - "provider_type": trace.ProviderType, - } - if trace.Command != "" { - params["command"] = trace.Command - } - rec.GenerationParams = params - } - - provJSON, err := json.Marshal(rec) - if err != nil { - return results, fmt.Errorf("engine: marshal provenance for stage %q: %w", stage.Stage, err) - } - - a := &artifacts.Artifact{ - ID: artifactID, - Type: artifacts.Type(stage.ArtifactType), - Content: content, - Provenance: string(provJSON), - } - - // -- Step 5: Write artifact to disk -- - if err := artifacts.Write(req.ArtifactDir, a); err != nil { - return results, fmt.Errorf("engine: write artifact stage %q: %w", stage.Stage, err) - } - - // -- Step 6: Append provenance record to JSONL log -- - if req.ProvenancePath != "" { - if err := provenance.AppendRecord(req.ProvenancePath, rec); err != nil { - return results, fmt.Errorf("engine: append provenance stage %q: %w", stage.Stage, err) - } - } else { - // default: provenance.jsonl in artifact dir - defaultPath := filepath.Join(req.ArtifactDir, "provenance.jsonl") - if err := provenance.AppendRecord(defaultPath, rec); err != nil { - return results, fmt.Errorf("engine: append provenance stage %q: %w", stage.Stage, err) - } - } - - results = append(results, &StageResult{Stage: stage.Stage, Artifact: a}) - } - - return results, nil -} diff --git a/internal/workflow/engine_integration_test.go b/internal/workflow/engine_integration_test.go deleted file mode 100644 index 777cd98..0000000 --- a/internal/workflow/engine_integration_test.go +++ /dev/null @@ -1,168 +0,0 @@ -//go:build integration - -package workflow_test - -import ( - "bufio" - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/generation" - "github.com/eight-acres-lab/openmelon/internal/project" - "github.com/eight-acres-lab/openmelon/internal/provenance" - "github.com/eight-acres-lab/openmelon/internal/skillplus" - "github.com/eight-acres-lab/openmelon/internal/workflow" -) - -// TestEngine_runEndToEnd runs the engine with: -// - a fake Python3 compiler script that returns a valid CompiledSkill JSON -// - a ShellProvider using "cat" (echoes stdin to stdout as the generated content) -// - project.json from examples/food-exploration/project.json -// -// It verifies that artifact files and provenance.jsonl are created correctly. -func TestEngine_runEndToEnd(t *testing.T) { - // --- Setup: fake Python compiler --- - tmpDir := t.TempDir() - fakePython := filepath.Join(tmpDir, "fake_python3") - fakeCompilerOutput := `{ - "target": "openmelon", - "package": {"id": "food-street-realism", "version": "1.0.0"}, - "compiled_prompt": "A vivid street food photo", - "runtime_vars": {"realism_level": "high"}, - "model_profile": "image_generator", - "evaluation": {"checklist": ["check focus", "check lighting"]} - }` - script := "#!/bin/sh\ncat << 'ENDJSON'\n" + fakeCompilerOutput + "\nENDJSON\n" - if err := os.WriteFile(fakePython, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - - // --- Setup: artifact and provenance dirs --- - artifactDir := filepath.Join(tmpDir, "artifacts") - provPath := filepath.Join(tmpDir, "provenance.jsonl") - - // --- Load project --- - // Use the canonical example project relative to workspace root. - projectPath := "../../../examples/food-exploration/project.json" - if _, err := os.Stat(projectPath); os.IsNotExist(err) { - t.Skip("example project.json not found, skipping integration test") - } - proj, err := project.Load(projectPath) - if err != nil { - t.Fatalf("project.Load: %v", err) - } - - // --- Load workflows --- - workflows, err := workflow.LoadWorkflows(projectPath) - if err != nil { - t.Fatalf("workflow.LoadWorkflows: %v", err) - } - var wfDef *workflow.WorkflowDefinition - for _, wf := range workflows { - wfDef = wf - break - } - if wfDef == nil { - t.Fatal("no workflow found") - } - - // --- Build request --- - compiler := &skillplus.Compiler{ - CompilerPath: tmpDir, - PythonCmd: fakePython, - } - provider := &generation.ShellProvider{Command: "cat"} // echoes stdin (prompt) back as output - - req := &workflow.RunRequest{ - Project: proj, - WorkflowDef: wfDef, - Intent: "show a real late-night noodle shop vibe", - ArtifactDir: artifactDir, - CompilerPath: tmpDir, - ProvenancePath: provPath, - Compiler: compiler, - Provider: provider, - Generate: true, - } - - // --- Run --- - engine := &workflow.Engine{} - results, err := engine.Run(context.Background(), req) - if err != nil { - t.Fatalf("engine.Run: %v", err) - } - if len(results) == 0 { - t.Fatal("expected at least one stage result") - } - - // --- Verify artifact files --- - for _, r := range results { - if r.Artifact == nil { - t.Errorf("stage %q: artifact is nil", r.Stage) - continue - } - // Content file should exist - entries, err := os.ReadDir(artifactDir) - if err != nil { - t.Fatalf("read artifact dir: %v", err) - } - found := false - for _, e := range entries { - if len(e.Name()) > 0 && e.Name()[:16] == r.Artifact.ID[:min(16, len(r.Artifact.ID))] { - found = true - } - } - if !found { - t.Errorf("stage %q: no artifact file found for id %s", r.Stage, r.Artifact.ID) - } - - // Provenance file for artifact should exist - provFile := filepath.Join(artifactDir, r.Artifact.ID+".provenance.json") - if _, err := os.Stat(provFile); err != nil { - t.Errorf("stage %q: provenance file missing: %v", r.Stage, err) - } - } - - // --- Verify provenance.jsonl --- - f, err := os.Open(provPath) - if err != nil { - t.Fatalf("provenance.jsonl not created: %v", err) - } - defer f.Close() - - var records []provenance.Record - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - var rec provenance.Record - if err := json.Unmarshal([]byte(line), &rec); err != nil { - t.Fatalf("unmarshal provenance line: %v", err) - } - records = append(records, rec) - } - - if len(records) != len(results) { - t.Errorf("provenance records = %d, want %d", len(records), len(results)) - } - for _, rec := range records { - if rec.ArtifactID == "" { - t.Error("provenance record missing artifact_id") - } - if rec.ProjectID != proj.ID { - t.Errorf("provenance project_id = %q, want %q", rec.ProjectID, proj.ID) - } - } -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/workflow/engine_test.go b/internal/workflow/engine_test.go deleted file mode 100644 index f52e62a..0000000 --- a/internal/workflow/engine_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package workflow - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/eight-acres-lab/openmelon/internal/project" - "github.com/eight-acres-lab/openmelon/internal/skillplus" -) - -// fakeCompiledSkillJSON is a valid Python compiler output for unit tests. -const fakeCompiledSkillJSON = `{ - "target": "openmelon", - "package": {"id": "unit-test-pkg", "version": "0.1.0"}, - "compiled_prompt": "unit test compiled prompt", - "runtime_vars": {"key": "val"}, - "model_profile": "image_generator", - "evaluation": {"checklist": ["check A"]} -}` - -// setupFakeCompilerScript writes a fake skillplus executable to dir and returns its path. -func setupFakeCompilerScript(t *testing.T, dir string) string { - t.Helper() - path := filepath.Join(dir, "fake_skillplus") - script := "#!/bin/sh\ncat << 'EOF'\n" + fakeCompiledSkillJSON + "\nEOF\n" - if err := os.WriteFile(path, []byte(script), 0o755); err != nil { - t.Fatalf("write fake skillplus: %v", err) - } - return path -} - -func TestEngine_run_compileOnly(t *testing.T) { - tmpDir := t.TempDir() - fakeSkillplus := setupFakeCompilerScript(t, tmpDir) - artifactDir := filepath.Join(tmpDir, "artifacts") - - proj := &project.Project{ - ID: "unit-proj", - Name: "Unit Test Project", - Platform: "test", - Audience: "testers", - Persona: "test persona", - } - - wfDef := &WorkflowDefinition{ - ID: "unit_flow", - Name: "Unit Flow", - Vertical: "test", - Stages: []StageDefinition{ - { - Stage: StageVisualConcretization, - SkillPlusPackage: "/fake/package.skillplus", - CompileTarget: "openmelon", - ModelProfile: "image_generator", - ArtifactType: "image_prompt", - }, - }, - } - - compiler := &skillplus.Compiler{ - SkillplusBinary: fakeSkillplus, - } - - engine := &Engine{} - req := &RunRequest{ - Project: proj, - WorkflowDef: wfDef, - Intent: "test intent", - ArtifactDir: artifactDir, - ProvenancePath: filepath.Join(tmpDir, "provenance.jsonl"), - Compiler: compiler, - Provider: nil, - Generate: false, // compile-only: no provider needed - } - - results, err := engine.Run(context.Background(), req) - if err != nil { - t.Fatalf("engine.Run error: %v", err) - } - if len(results) != 1 { - t.Fatalf("expected 1 result, got %d", len(results)) - } - - r := results[0] - if r.Stage != StageVisualConcretization { - t.Errorf("stage = %q, want %q", r.Stage, StageVisualConcretization) - } - if r.Artifact == nil { - t.Fatal("artifact is nil") - } - if r.Artifact.Content != "unit test compiled prompt" { - t.Errorf("content = %q, want compiled prompt text", r.Artifact.Content) - } - - // Artifact content file must exist on disk. - entries, err := os.ReadDir(artifactDir) - if err != nil { - t.Fatalf("read artifact dir: %v", err) - } - if len(entries) == 0 { - t.Error("expected artifact files in artifact dir, got none") - } - - // Provenance JSONL must exist. - if _, err := os.Stat(filepath.Join(tmpDir, "provenance.jsonl")); err != nil { - t.Errorf("provenance.jsonl not created: %v", err) - } -} - -func TestEngine_run_contextCancelled(t *testing.T) { - tmpDir := t.TempDir() - fakeSkillplus := setupFakeCompilerScript(t, tmpDir) - - proj := &project.Project{ - ID: "p", Name: "P", Platform: "x", - } - wfDef := &WorkflowDefinition{ - ID: "flow", - Stages: []StageDefinition{ - {Stage: StageVisualConcretization, SkillPlusPackage: "/pkg", CompileTarget: "openmelon", ModelProfile: "m", ArtifactType: "image_prompt"}, - }, - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel immediately - - engine := &Engine{} - _, err := engine.Run(ctx, &RunRequest{ - Project: proj, - WorkflowDef: wfDef, - ArtifactDir: filepath.Join(tmpDir, "art"), - Compiler: &skillplus.Compiler{SkillplusBinary: fakeSkillplus}, - }) - - if err == nil { - t.Fatal("expected error for cancelled context, got nil") - } -} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go deleted file mode 100644 index e2c55b3..0000000 --- a/internal/workflow/workflow.go +++ /dev/null @@ -1,22 +0,0 @@ -package workflow - -// Stage identifies a content production stage. -type Stage string - -const ( - StageIntentPlanning Stage = "intent_planning" - StageAngleSelection Stage = "angle_selection" - StageCopywriting Stage = "copywriting" - StageVisualConcretization Stage = "visual_concretization" - StageGeneration Stage = "generation" - StageReview Stage = "review" - StagePackaging Stage = "packaging" -) - -// Workflow is a staged content production process. -type Workflow struct { - ID string - Vertical string - Stages []Stage - CurrentStage Stage -} diff --git a/npm/README.md b/npm/README.md deleted file mode 100644 index db57da1..0000000 --- a/npm/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# @e8s/openmelon - -A content-creation agent for the terminal. The product TUI is moving to a -TS/Ink interface; the release package currently keeps the existing native -runtime wrapper while that bridge is completed. - -```bash -npm install -g @e8s/openmelon @e8s/skillplus -cd path/to/your-project -openmelon -``` - -This package is a Node shim that downloads the matching [openmelon](https://github.com/eight-acres-lab/openmelon) binary from GitHub Releases at install time, verified against `SHASUMS256.txt`. Platforms: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`. - -See [the main README](https://github.com/eight-acres-lab/openmelon#readme) for the full reference. - -## Override the binary - -```bash -# Skip the download (provide your own binary out-of-band) -OPENMELON_SKIP_DOWNLOAD=1 npm install -g @e8s/openmelon - -# Point at a local build -export OPENMELON_BIN=/path/to/your/openmelon -openmelon -``` - -## License - -[Apache 2.0](https://github.com/eight-acres-lab/openmelon/blob/main/LICENSE). diff --git a/npm/bin/openmelon.js b/npm/bin/openmelon.js deleted file mode 100755 index dd78db8..0000000 --- a/npm/bin/openmelon.js +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -// Shim: spawn the platform binary downloaded by install.js. - -import { spawn } from "node:child_process" -import { existsSync } from "node:fs" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const FALLBACK = process.platform === "win32" ? "openmelon-bin.exe" : "openmelon-bin" -const BIN = process.env.OPENMELON_BIN || join(__dirname, FALLBACK) - -if (!existsSync(BIN)) { - console.error(`[openmelon] binary not found at ${BIN}`) - console.error(`[openmelon] re-run \`npm install -g @e8s/openmelon\` to fetch it,`) - console.error(`[openmelon] or set OPENMELON_BIN=/path/to/openmelon to point at a local build.`) - process.exit(127) -} - -const child = spawn(BIN, process.argv.slice(2), { stdio: "inherit" }) -child.on("exit", (code, signal) => { - if (signal) process.kill(process.pid, signal) - process.exit(code ?? 1) -}) -child.on("error", (err) => { - console.error(`[openmelon] failed to spawn ${BIN}: ${err.message}`) - process.exit(1) -}) diff --git a/npm/install.js b/npm/install.js deleted file mode 100755 index 481ad22..0000000 --- a/npm/install.js +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env node -// postinstall: download the openmelon binary for the current platform -// from this version's GitHub Release. -// -// Skipped when: -// - OPENMELON_SKIP_DOWNLOAD=1 (CI / dev override) -// - OPENMELON_BIN points at an existing binary (use that instead) -// - the binary already exists at bin/openmelon-bin (re-run / cache) - -import { existsSync, mkdirSync, chmodSync, createWriteStream } from "node:fs" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" -import { createHash } from "node:crypto" -import { readFileSync } from "node:fs" -import { get } from "node:https" - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const BIN_DIR = join(__dirname, "bin") -const BIN_PATH = join(BIN_DIR, process.platform === "win32" ? "openmelon-bin.exe" : "openmelon-bin") - -if (process.env.OPENMELON_SKIP_DOWNLOAD === "1") { - console.log("[openmelon] OPENMELON_SKIP_DOWNLOAD=1 — skipping binary download") - process.exit(0) -} - -if (process.env.OPENMELON_BIN && existsSync(process.env.OPENMELON_BIN)) { - console.log(`[openmelon] OPENMELON_BIN=${process.env.OPENMELON_BIN} — using that binary`) - process.exit(0) -} - -if (existsSync(BIN_PATH)) { - console.log(`[openmelon] binary already present at ${BIN_PATH}`) - process.exit(0) -} - -// Map Node platform/arch → release artifact suffix. -const PLATFORM_MAP = { - "darwin-arm64": "darwin-arm64", - "darwin-x64": "darwin-amd64", - "linux-arm64": "linux-arm64", - "linux-x64": "linux-amd64", -} - -const key = `${process.platform}-${process.arch}` -const suffix = PLATFORM_MAP[key] -if (!suffix) { - console.error(`[openmelon] no prebuilt binary for ${key}.`) - console.error(`[openmelon] supported: ${Object.keys(PLATFORM_MAP).join(", ")}`) - console.error(`[openmelon] build from source: https://github.com/eight-acres-lab/openmelon`) - process.exit(1) -} - -const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")) -const VERSION = `v${pkg.version}` -const BASE = `https://github.com/eight-acres-lab/openmelon/releases/download/${VERSION}` -const FILE = `openmelon-${VERSION}-${suffix}` -const URL = `${BASE}/${FILE}` -const SHASUMS_URL = `${BASE}/SHASUMS256.txt` - -console.log(`[openmelon] downloading ${URL}`) - -mkdirSync(BIN_DIR, { recursive: true }) - -await downloadAndVerify(URL, SHASUMS_URL, FILE, BIN_PATH) -chmodSync(BIN_PATH, 0o755) -console.log(`[openmelon] installed → ${BIN_PATH}`) - -// --- helpers --- - -function fetchToFile(url, dest) { - return new Promise((resolve, reject) => { - const file = createWriteStream(dest) - const req = get(url, { headers: { "user-agent": "openmelon-installer" } }, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - // GitHub release downloads redirect to S3. - file.close() - fetchToFile(res.headers.location, dest).then(resolve).catch(reject) - return - } - if (res.statusCode !== 200) { - reject(new Error(`download ${url}: HTTP ${res.statusCode}`)) - return - } - res.pipe(file) - file.on("finish", () => file.close(resolve)) - }) - req.on("error", reject) - }) -} - -function fetchText(url) { - return new Promise((resolve, reject) => { - const req = get(url, { headers: { "user-agent": "openmelon-installer" } }, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - fetchText(res.headers.location).then(resolve).catch(reject) - return - } - if (res.statusCode !== 200) { - reject(new Error(`fetch ${url}: HTTP ${res.statusCode}`)) - return - } - let data = "" - res.setEncoding("utf8") - res.on("data", (chunk) => (data += chunk)) - res.on("end", () => resolve(data)) - }) - req.on("error", reject) - }) -} - -async function downloadAndVerify(url, shasumsURL, filename, dest) { - let expected - try { - const shasums = await fetchText(shasumsURL) - for (const line of shasums.split("\n")) { - const [hash, name] = line.trim().split(/\s+/) - if (name === filename) { - expected = hash - break - } - } - } catch (err) { - console.warn(`[openmelon] WARNING: could not fetch SHASUMS256.txt (${err.message}); skipping integrity check`) - } - - await fetchToFile(url, dest) - - if (expected) { - const actual = createHash("sha256").update(readFileSync(dest)).digest("hex") - if (actual !== expected) { - throw new Error( - `[openmelon] sha256 mismatch for ${filename}: expected ${expected}, got ${actual}. ` + - `Refusing to install a tampered binary.`, - ) - } - console.log(`[openmelon] sha256 verified: ${actual.slice(0, 16)}…`) - } -} diff --git a/npm/package.json b/npm/package.json deleted file mode 100644 index 265e3b3..0000000 --- a/npm/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@e8s/openmelon", - "version": "0.3.0", - "description": "A content-creation agent that runs in your terminal.", - "license": "Apache-2.0", - "homepage": "https://github.com/eight-acres-lab/openmelon", - "repository": { - "type": "git", - "url": "git+https://github.com/eight-acres-lab/openmelon.git", - "directory": "npm" - }, - "bugs": { - "url": "https://github.com/eight-acres-lab/openmelon/issues" - }, - "keywords": [ - "openmelon", - "ai-agent", - "content-generation", - "skillplus", - "cli" - ], - "type": "module", - "bin": { - "openmelon": "bin/openmelon.js" - }, - "files": [ - "bin", - "install.js", - "README.md" - ], - "scripts": { - "postinstall": "node install.js" - }, - "engines": { - "node": ">=18" - } -} diff --git a/pkg/contracts/contracts.go b/pkg/contracts/contracts.go deleted file mode 100644 index c5e32af..0000000 --- a/pkg/contracts/contracts.go +++ /dev/null @@ -1,4 +0,0 @@ -package contracts - -// ArtifactType is the public name of an OpenMelon artifact category. -type ArtifactType string diff --git a/pkg/openmelon/openmelon.go b/pkg/openmelon/openmelon.go deleted file mode 100644 index b10025d..0000000 --- a/pkg/openmelon/openmelon.go +++ /dev/null @@ -1,4 +0,0 @@ -package openmelon - -// Version identifies the current OpenMelon package version. -const Version = "0.3.0" diff --git a/rust-toolchain.toml b/rust-toolchain.toml deleted file mode 100644 index 6ac328d..0000000 --- a/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "stable" -profile = "minimal" -components = ["rustfmt"] diff --git a/rust/Cargo.lock b/rust/Cargo.lock deleted file mode 100644 index 6c9db17..0000000 --- a/rust/Cargo.lock +++ /dev/null @@ -1,1714 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "endian-type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openmelon-tui" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64", - "clap", - "libc", - "reqwest", - "rustyline", - "serde", - "serde_json", - "sha2", - "time", - "unicode-width 0.2.2", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases 0.2.1", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.59.0", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "radix_trie" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" -dependencies = [ - "endian-type", - "nibble_vec", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rustyline" -version = "14.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" -dependencies = [ - "bitflags", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix", - "radix_trie", - "unicode-segmentation", - "unicode-width 0.1.14", - "utf8parse", - "windows-sys 0.52.0", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml deleted file mode 100644 index 50bb040..0000000 --- a/rust/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[workspace] -resolver = "2" -members = ["crates/openmelon-tui"] - -[workspace.package] -edition = "2021" -license = "MIT" -repository = "https://github.com/eight-acres-lab/openmelon" diff --git a/rust/crates/openmelon-tui/Cargo.toml b/rust/crates/openmelon-tui/Cargo.toml deleted file mode 100644 index 56d24d1..0000000 --- a/rust/crates/openmelon-tui/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "openmelon-tui" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -anyhow = "1" -base64 = "0.22" -clap = { version = "4", features = ["derive"] } -libc = "0.2" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } -rustyline = "14" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -time = { version = "0.3", features = ["formatting", "macros"] } -unicode-width = "0.2" diff --git a/rust/crates/openmelon-tui/src/app.rs b/rust/crates/openmelon-tui/src/app.rs deleted file mode 100644 index de37204..0000000 --- a/rust/crates/openmelon-tui/src/app.rs +++ /dev/null @@ -1,1050 +0,0 @@ -#![allow(dead_code)] - -use std::io::{self, Write}; -use std::path::PathBuf; -use std::process::Command; -use std::sync::{Arc, Mutex}; - -use anyhow::{bail, Context, Result}; -use base64::Engine; - -use crate::config::{default_provider, load_user_config, resolve_provider}; -use crate::image::ImageGenerator; -use crate::llm::{Message, OpenAIClient}; -use crate::project::{set_project_default, set_project_setting, Workspace}; -use crate::render::{ - divider, render_block, render_history as render_transcript_history, render_plain_transcript, - Block, BlockKind, -}; -use crate::runtime::{RunInput, Runtime}; -use crate::session::{load_events, load_history, ProjectLayout, Session}; -use crate::terminal::{Input, LineEditor}; -use crate::tools::{ToolEnv, ToolRegistry}; - -mod event_tui; - -#[derive(Debug, Clone)] -pub struct AppOptions { - pub workdir: PathBuf, - pub provider: Option, - pub model: Option, - pub base_url: Option, - pub reasoning_effort: Option, - pub image_provider: Option, - pub image_model: Option, - pub image_base_url: Option, - pub max_steps: usize, -} - -pub struct App { - workspace: Workspace, - editor: LineEditor, - options: AppOptions, - resumed_from: Option, - initial_history: Vec, - provider: String, - model: String, - base_url: String, - reasoning_effort: String, - image_provider: String, - image_model: String, - image_base_url: String, - active_skill: String, - allowed_bash: Arc>>, -} - -pub struct DemoApp { - layout: ProjectLayout, - editor: LineEditor, - width: usize, -} - -impl App { - pub fn new(options: AppOptions, resumed_from: Option) -> Result { - let workspace = Workspace::discover(&options.workdir)?; - let user_config = load_user_config()?; - - let mut provider = first_non_empty([ - options.provider.as_deref(), - Some(workspace.project.defaults.llm_provider.as_str()), - Some(user_config.defaults.llm_provider.as_str()), - ]) - .unwrap_or_else(default_provider); - if provider == "auto" { - provider = default_provider(); - } - let model = first_non_empty([ - options.model.as_deref(), - Some(workspace.project.defaults.llm_model.as_str()), - Some(user_config.defaults.llm_model.as_str()), - ]) - .unwrap_or_default(); - let reasoning_effort = first_non_empty([ - options.reasoning_effort.as_deref(), - Some(workspace.project.settings.reasoning_effort.as_str()), - Some(user_config.defaults.reasoning_effort.as_str()), - ]) - .unwrap_or_else(|| default_reasoning_effort(&provider, &model)); - let image_provider = first_non_empty([ - options.image_provider.as_deref(), - Some(workspace.project.defaults.image_provider.as_str()), - Some(user_config.defaults.image_provider.as_str()), - ]) - .unwrap_or_default(); - let image_model = first_non_empty([ - options.image_model.as_deref(), - Some(workspace.project.defaults.image_model.as_str()), - Some(user_config.defaults.image_model.as_str()), - ]) - .unwrap_or_default(); - - let provider_resolution = - resolve_provider(&workspace.root, &workspace.project, &user_config, &provider)?; - let base_url = options - .base_url - .clone() - .unwrap_or(provider_resolution.base_url); - let image_base_url = options.image_base_url.clone().unwrap_or_default(); - let editor = LineEditor::new(workspace.state_dir().join("rust-tui-history.txt"))?; - let initial_history = if let Some(id) = &resumed_from { - load_history(&workspace.root, id).with_context(|| format!("resume {id}"))? - } else { - Vec::new() - }; - - Ok(Self { - workspace, - editor, - options, - resumed_from, - initial_history, - provider, - model, - base_url, - reasoning_effort, - image_provider, - image_model, - image_base_url, - active_skill: String::new(), - allowed_bash: Arc::new(Mutex::new(std::collections::BTreeSet::new())), - }) - } - - pub fn run(self) -> Result<()> { - self.run_plain() - } - - pub fn run_event_tui(self) -> Result<()> { - event_tui::run(self) - } - - pub fn run_plain(mut self) -> Result<()> { - let mut history = self.initial_history.clone(); - let mut session = self.create_session("interactive REPL")?; - self.print_header(&session)?; - if !history.is_empty() { - println!("loaded {} prior messages", history.len()); - render_history(&history); - } - - loop { - match self.editor.read("› ")? { - Input::Line(line) => { - let text = line.trim(); - if text.is_empty() { - continue; - } - if text.starts_with('/') { - if self.handle_slash(text, &mut history, &session)? { - break; - } - continue; - } - let user_text = self.apply_active_skill(text); - let result = self.run_turn(&mut session, user_text, history)?; - history = result; - } - Input::Interrupted => { - println!("input cleared"); - } - Input::Eof => break, - } - } - - Ok(()) - } - - pub fn run_one_shot(self, prompt: String) -> Result<()> { - let history = self.initial_history.clone(); - let mut session = self.create_session(&prompt.chars().take(80).collect::())?; - let _ = self.run_turn(&mut session, prompt, history)?; - Ok(()) - } - - fn create_session(&self, intent: &str) -> Result { - Session::create( - &self.workspace.root, - &self.workspace.project.id, - intent, - self.resumed_from.as_deref(), - ) - } - - fn run_turn( - &self, - session: &mut Session, - prompt: String, - history: Vec, - ) -> Result> { - if self.provider == "anthropic" { - bail!("Rust runtime does not support Anthropic yet; use openai/openrouter for this branch"); - } - let user_config = load_user_config()?; - let llm_res = resolve_provider( - &self.workspace.root, - &self.workspace.project, - &user_config, - &self.provider, - )?; - let llm = OpenAIClient::new( - &self.provider, - llm_res.api_key, - first_non_empty([ - Some(self.base_url.as_str()), - Some(llm_res.base_url.as_str()), - ]) - .unwrap_or_default(), - self.model.clone(), - )?; - - session.set_runtime_info(llm.provider(), llm.model())?; - session.append_prompt("user", &prompt)?; - - let image = self.build_image_generator(&user_config).ok(); - let tool_env = ToolEnv { - workspace: self.workspace.clone(), - session_id: session.id.clone(), - session_dir: session.dir.clone(), - image, - bash_mode: effective_bash_mode(&self.workspace.project.settings.bash_permission_mode), - allowed_bash: self.allowed_bash.clone(), - approve_bash: None, - }; - let registry = ToolRegistry::standard(&tool_env); - let system_prompt = build_project_system_prompt(&self.workspace, ®istry.names()); - self.print_context_status(®istry, &tool_env); - let mut runtime = Runtime { - llm, - registry, - env: tool_env, - max_steps: self.options.max_steps, - reasoning_effort: self.reasoning_effort.clone(), - drain_user_input: None, - events: None, - }; - - let history_len = history.len(); - let result = runtime.run( - RunInput { - system_prompt, - user_input: prompt, - history, - }, - session, - )?; - let delta = if history_len <= result.messages.len() { - &result.messages[history_len..] - } else { - result.messages.as_slice() - }; - session.append_messages(delta)?; - session.write_summary( - &result.finish_summary, - &result.finish_artifacts, - result.finished, - )?; - println!("session {}", session.id); - Ok(result.messages) - } - - fn build_image_generator( - &self, - user_config: &crate::config::UserConfig, - ) -> Result { - if self.image_provider.trim().is_empty() || self.image_model.trim().is_empty() { - bail!("image generation is not configured"); - } - let resolved = resolve_provider( - &self.workspace.root, - &self.workspace.project, - user_config, - &self.image_provider, - )?; - ImageGenerator::new( - &self.image_provider, - resolved.api_key, - first_non_empty([ - Some(self.image_base_url.as_str()), - Some(resolved.base_url.as_str()), - ]) - .unwrap_or_default(), - self.image_model.clone(), - ) - } - - fn print_header(&self, session: &Session) -> Result<()> { - const RESET: &str = "\x1b[0m"; - const BOLD: &str = "\x1b[1m"; - const CYAN: &str = "\x1b[36m"; - - println!("{BOLD}{CYAN}OpenMelon{RESET}"); - println!( - "project · {} · {} · model {}:{} · reasoning {}", - self.workspace.project.name, - self.workspace.root.display(), - self.provider, - self.model, - empty_as_auto(&self.reasoning_effort) - ); - if !self.image_model.is_empty() { - println!( - "image · {}:{}", - empty_as_none(&self.image_provider), - self.image_model - ); - } - println!("outputs · {}", self.workspace.outputs_dir().display()); - println!("session {}", session.id); - if let Some(resumed) = &self.resumed_from { - println!("resumed from {resumed}"); - } - println!("Type a request, /help for commands, Ctrl-C clears input, Ctrl-D exits."); - println!(); - io::stdout().flush()?; - Ok(()) - } - - fn handle_slash( - &mut self, - text: &str, - history: &mut Vec, - session: &Session, - ) -> Result { - let parts = text.split_whitespace().collect::>(); - match parts.first().copied().unwrap_or_default() { - "/exit" | "/quit" | "/q" => Ok(true), - "/help" => { - println!(" /help show commands"); - println!(" /status show project/model status"); - println!(" /history render current conversation history"); - println!(" /clear clear in-memory conversation history"); - println!(" /session print current session directory"); - println!(" /save PATH save current history as JSONL"); - println!(" /copy print OSC52 clipboard sequence for transcript"); - println!(" /events show recent session events"); - println!(" /model ID switch LLM model and persist project default"); - println!(" /model-image off | [PROVIDER] MODEL"); - println!(" /settings bash strict|auto|trusted"); - println!(" /settings reasoning auto|medium|high|xhigh"); - println!(" /skill list skills"); - println!(" /skill ID apply a skillplus package to the next message"); - println!(" /space ID show a creative space summary"); - println!(" /compact ID print a compaction draft"); - println!(" /exit exit"); - Ok(false) - } - "/status" => { - println!( - "project: {} ({})", - self.workspace.project.name, self.workspace.project.id - ); - println!( - "model: {}:{} reasoning={}", - self.provider, - self.model, - empty_as_auto(&self.reasoning_effort) - ); - println!("outputs: {}", self.workspace.outputs_dir().display()); - Ok(false) - } - "/history" => { - render_history(history); - Ok(false) - } - "/clear" => { - history.clear(); - println!("history cleared"); - Ok(false) - } - "/session" => { - println!("{}", session.dir.display()); - Ok(false) - } - "/save" => { - let Some(path) = parts.get(1) else { - bail!("/save: usage /save "); - }; - save_history_jsonl(history, path)?; - println!("saved {} messages -> {}", history.len(), path); - Ok(false) - } - "/copy" => { - let text = plain_transcript(history); - if text.trim().is_empty() { - println!("nothing to copy"); - } else { - print_osc52(&text)?; - println!("copied transcript ({} chars)", text.chars().count()); - } - Ok(false) - } - "/events" => { - let events = load_events(&session.dir, 20)?; - if events.is_empty() { - println!("(no events recorded yet)"); - } else { - for event in events { - println!("{}", serde_json::to_string(&event)?); - } - } - Ok(false) - } - "/model" => { - let Some(model) = parts.get(1) else { - bail!("/model: usage /model "); - }; - self.model = (*model).to_string(); - set_project_default(&self.workspace.root, "llm_model", &self.model)?; - println!("model: {}:{}", self.provider, self.model); - Ok(false) - } - "/model-image" => { - if parts.get(1).is_none() { - bail!("/model-image: usage /model-image off | [provider] "); - } - if matches!(parts.get(1), Some(&"off" | &"disable" | &"none")) { - self.image_provider.clear(); - self.image_model.clear(); - set_project_default(&self.workspace.root, "image_provider", "")?; - set_project_default(&self.workspace.root, "image_model", "")?; - println!("image generation disabled"); - return Ok(false); - } - let (provider, model) = if parts.len() >= 3 { - (parts[1], parts[2]) - } else { - ( - if self.image_provider.is_empty() { - self.provider.as_str() - } else { - self.image_provider.as_str() - }, - parts[1], - ) - }; - self.image_provider = provider.to_string(); - self.image_model = model.to_string(); - set_project_default(&self.workspace.root, "image_provider", &self.image_provider)?; - set_project_default(&self.workspace.root, "image_model", &self.image_model)?; - println!("image model: {}:{}", self.image_provider, self.image_model); - Ok(false) - } - "/settings" => { - self.handle_settings(&parts)?; - Ok(false) - } - "/skill" => { - self.handle_skill(&parts)?; - Ok(false) - } - "/space" => { - self.print_space(&parts)?; - Ok(false) - } - "/compact" => { - self.print_compact(&parts)?; - Ok(false) - } - other => { - println!( - "{}", - render_block( - &Block { - kind: BlockKind::Error, - body: format!("unknown command: {other}"), - }, - 88, - ) - ); - Ok(false) - } - } - } - - fn apply_active_skill(&mut self, text: &str) -> String { - if self.active_skill.is_empty() { - return text.to_string(); - } - let skill = std::mem::take(&mut self.active_skill); - format!( - "Apply the skill {skill:?} to this request: first call compile_skill with skill={skill:?} (BARE slug, no 'skillplus:' prefix) to fetch the package's prompt + output schema, then proceed.\n\n{text}" - ) - } - - fn handle_skill(&mut self, parts: &[&str]) -> Result<()> { - if parts.len() == 1 { - let skills = list_skillplus()?; - if skills.is_empty() { - println!("(no skillplus packages found)"); - } else { - for skill in skills { - println!( - " {} {}", - skill - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""), - skill - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - ); - } - } - println!("usage: /skill or /skill clear"); - return Ok(()); - } - let arg = parts[1]; - if matches!(arg, "clear" | "off" | "none") { - self.active_skill.clear(); - println!("skill cleared"); - return Ok(()); - } - self.active_skill = arg.to_string(); - println!("skill: {} applies to your next message", self.active_skill); - Ok(()) - } - - fn print_space(&self, parts: &[&str]) -> Result<()> { - let Some(space_id) = parts.get(1) else { - bail!("/space: usage /space "); - }; - let tool_env = self.tool_env(None, ""); - let packet = ToolRegistry::standard(&tool_env).dispatch( - &tool_env, - "get_context_packet", - serde_json::json!({ "space_id": space_id }), - )?; - if let Some(err) = packet.get("error").and_then(serde_json::Value::as_str) { - bail!("/space: {err}"); - } - let space = packet.get("space").cloned().unwrap_or_default(); - println!( - "{} ({}) {}", - space - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or(*space_id), - space - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - space - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - ); - println!( - " {} decisions · {} feedback · {} episodes · {} assets", - packet - .get("recent_decisions") - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .unwrap_or(0), - packet - .get("recent_feedback") - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .unwrap_or(0), - packet - .get("recent_episodes") - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .unwrap_or(0), - packet - .get("assets") - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .unwrap_or(0) - ); - Ok(()) - } - - fn print_compact(&self, parts: &[&str]) -> Result<()> { - let Some(space_id) = parts.get(1) else { - bail!("/compact: usage /compact "); - }; - let tool_env = self.tool_env(None, ""); - let packet = ToolRegistry::standard(&tool_env).dispatch( - &tool_env, - "get_context_packet", - serde_json::json!({ "space_id": space_id }), - )?; - if let Some(err) = packet.get("error").and_then(serde_json::Value::as_str) { - bail!("/compact: {err}"); - } - println!("{}", render_compaction_draft(&packet)); - Ok(()) - } - - fn handle_settings(&mut self, parts: &[&str]) -> Result<()> { - if parts.len() == 1 { - println!( - "bash_permission_mode: {}", - effective_bash_mode(&self.workspace.project.settings.bash_permission_mode) - ); - println!( - "reasoning_effort: {}", - empty_as_auto(&self.reasoning_effort) - ); - return Ok(()); - } - match parts.get(1).copied() { - Some("bash") => { - let Some(mode) = parts.get(2).copied() else { - bail!("/settings bash: expected strict|auto|trusted"); - }; - if !matches!(mode, "strict" | "auto" | "trusted") { - bail!("/settings bash: expected strict|auto|trusted"); - } - set_project_setting(&self.workspace.root, "bash_permission_mode", mode)?; - self.workspace.project.settings.bash_permission_mode = mode.to_string(); - println!("bash_permission_mode: {mode}"); - } - Some("reasoning") => { - let Some(effort) = parts.get(2).copied() else { - bail!("/settings reasoning: expected auto|medium|high|xhigh"); - }; - if effort == "auto" { - set_project_setting(&self.workspace.root, "reasoning_effort", "")?; - self.reasoning_effort = default_reasoning_effort(&self.provider, &self.model); - println!( - "reasoning_effort: {}", - empty_as_auto(&self.reasoning_effort) - ); - } else { - if !matches!(effort, "medium" | "high" | "xhigh") { - bail!("/settings reasoning: expected auto|medium|high|xhigh"); - } - set_project_setting(&self.workspace.root, "reasoning_effort", effort)?; - self.reasoning_effort = effort.to_string(); - println!("reasoning_effort: {}", self.reasoning_effort); - } - } - _ => bail!("/settings: expected bash or reasoning"), - } - Ok(()) - } - - fn tool_env(&self, session: Option<&Session>, session_id: &str) -> ToolEnv { - ToolEnv { - workspace: self.workspace.clone(), - session_id: session - .map(|s| s.id.clone()) - .unwrap_or_else(|| session_id.to_string()), - session_dir: session - .map(|s| s.dir.clone()) - .unwrap_or_else(|| self.workspace.state_dir().join("sessions")), - image: None, - bash_mode: effective_bash_mode(&self.workspace.project.settings.bash_permission_mode), - allowed_bash: self.allowed_bash.clone(), - approve_bash: None, - } - } - - fn print_context_status(&self, registry: &ToolRegistry, env: &ToolEnv) { - let Ok(spaces) = registry.dispatch(env, "list_spaces", serde_json::json!({})) else { - return; - }; - let Some(items) = spaces.as_array() else { - return; - }; - if items.is_empty() { - return; - } - println!("continuity: {} creative spaces available", items.len()); - } -} - -impl DemoApp { - pub fn new(workdir: PathBuf) -> Result { - let layout = ProjectLayout::discover(workdir)?; - let editor = LineEditor::new(layout.history_file())?; - let width = terminal_width(); - - Ok(Self { - layout, - editor, - width, - }) - } - - pub fn run_demo(mut self) -> Result<()> { - self.print_header()?; - - loop { - match self.editor.read("> ")? { - Input::Line(line) => { - let command = line.trim(); - - if command.is_empty() { - continue; - } - - match command { - "/quit" | "/exit" => break, - "/help" => self.print_help()?, - "/status" => self.print_status()?, - "/tool" => self.print_tool_demo()?, - "/error" => self.print_error_demo()?, - _ => self.print_assistant_echo(command)?, - } - } - Input::Interrupted => { - println!("input cleared"); - } - Input::Eof => break, - } - } - - Ok(()) - } - - fn print_header(&self) -> Result<()> { - println!("openmelon rust tui demo"); - println!("{}", divider(self.width)); - println!("project: {}", self.layout.root().display()); - println!("outputs: {}", self.layout.outputs_dir().display()); - println!("type /help for commands, Ctrl-D to exit"); - println!(); - io::stdout().flush()?; - Ok(()) - } - - fn print_help(&self) -> Result<()> { - self.print_block(BlockKind::Assistant, DEMO_HELP) - } - - fn print_status(&self) -> Result<()> { - self.print_block( - BlockKind::Assistant, - &format!( - "# Status\n\n- Project: `{}`\n- Outputs: `{}`\n- TUI mode: normal scrollback prototype", - self.layout.root().display(), - self.layout.outputs_dir().display() - ), - ) - } - - fn print_tool_demo(&self) -> Result<()> { - self.print_block( - BlockKind::Tool, - "image_generate: writing visible creator output to outputs/artifacts/demo", - ) - } - - fn print_error_demo(&self) -> Result<()> { - self.print_block( - BlockKind::Error, - "This is an error block demo. Runtime errors and model/tool failures should use this visual channel.", - ) - } - - fn print_assistant_echo(&self, input: &str) -> Result<()> { - self.print_block( - BlockKind::Assistant, - &format!( - "# Draft turn\n\nThis Rust prototype received:\n\n> {}\n\n- Transcript output stays in normal terminal scrollback, so resize, selection, and copying remain native.\n- Runtime/model integration will be added after the TUI contract is stable.", - input - ), - ) - } - - fn print_block(&self, kind: BlockKind, body: &str) -> Result<()> { - println!(); - println!("{}", divider(self.width)); - println!( - "{}", - render_block( - &Block { - kind, - body: body.to_string() - }, - self.width - ) - ); - println!(); - io::stdout().flush()?; - Ok(()) - } -} - -impl App { - pub fn new_demo(workdir: PathBuf) -> Result { - DemoApp::new(workdir) - } -} - -const DEMO_HELP: &str = r#"# Commands - -- `/help` shows this help. -- `/status` prints project paths and current prototype mode. -- `/tool` prints a tool block demo. -- `/error` prints an error block demo. -- `/quit` exits the demo. - -This crate keeps output in normal terminal scrollback so native copy, scroll, and resize behavior stay predictable."#; - -fn build_project_system_prompt(workspace: &Workspace, tool_names: &[String]) -> String { - let p = &workspace.project; - let mut out = String::new(); - out.push_str( - "You are openmelon, a content-creation agent operating inside a creator's project.\n\n", - ); - out.push_str(&format!("Project: {} ({})\n", p.name, p.id)); - if !p.description.trim().is_empty() { - out.push_str(&format!("Description: {}\n", p.description)); - } - if !p.persona.trim().is_empty() { - out.push_str(&format!("Voice / persona: {}\n", p.persona)); - } - if !p.constraints.is_empty() { - out.push_str("House rules:\n"); - for constraint in &p.constraints { - out.push_str(&format!(" - {}\n", constraint)); - } - } - out.push_str("\nWork like a senior creator operating a durable creative workspace. Decide whether the request starts a new creative space, continues an existing space, modifies canon, records feedback, plans future content, or produces an episode. Load known spaces, characters, references, typography, layout rules, and reusable assets before production. Treat typography as descriptive continuity context and image prompt constraints, not a local font lookup. User-facing deliverables must be saved in visible project output directories such as outputs/; .openmelon is reserved for internal state, sessions, config, and continuity data. When done, call finish with a short summary and final artifact paths or updated continuity state.\n"); - out.push_str("\nAvailable tools: "); - out.push_str(&tool_names.join(", ")); - out.push('\n'); - out -} - -fn render_history(messages: &[Message]) { - print!( - "{}", - render_transcript_history(messages, 88, crate::render::TranscriptMode::Styled) - ); -} - -fn save_history_jsonl(history: &[Message], path: &str) -> Result<()> { - let mut file = std::fs::File::create(path)?; - for message in history { - serde_json::to_writer(&mut file, message)?; - file.write_all(b"\n")?; - } - Ok(()) -} - -fn plain_transcript(history: &[Message]) -> String { - render_plain_transcript(history, 88) -} - -fn render_compaction_draft(packet: &serde_json::Value) -> String { - let space = packet.get("space").cloned().unwrap_or_default(); - let name = space - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("Space"); - let id = space - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let status = space - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let mut out = String::new(); - out.push_str(&format!("# {name} Compaction\n\n")); - out.push_str(&format!("Space: {id} ({status})\n")); - - let canon = packet - .get("canon") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .trim(); - if !canon.is_empty() { - out.push_str("\n## Canon\n"); - out.push_str(canon); - out.push('\n'); - } - - push_compaction_rows( - &mut out, - "Confirmed Decisions", - packet.get("recent_decisions"), - |row| { - let decision = row - .get("decision") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let target = row - .get("target") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if target.is_empty() { - decision.to_string() - } else { - format!("{decision} [{target}]") - } - }, - ); - push_compaction_rows( - &mut out, - "Feedback Signals", - packet.get("recent_feedback"), - |row| { - let signal = row - .get("signal") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let recommendation = row - .get("recommendation") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if recommendation.is_empty() { - signal.to_string() - } else { - format!("{signal}: {recommendation}") - } - }, - ); - push_compaction_rows(&mut out, "Reusable Assets", packet.get("assets"), |row| { - let asset_id = row - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let status = row - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let weight = row - .get("weight") - .and_then(serde_json::Value::as_f64) - .unwrap_or(0.0); - let description = row - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - format!("{asset_id} ({status}, weight {weight:.2}): {description}") - }); - push_compaction_rows( - &mut out, - "Recent Episodes", - packet.get("recent_episodes"), - |row| { - let episode_id = row - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let topic = first_non_empty([ - row.get("topic").and_then(serde_json::Value::as_str), - row.get("title").and_then(serde_json::Value::as_str), - ]) - .unwrap_or_default(); - format!("{episode_id}: {topic}") - }, - ); - - out.trim().to_string() -} - -fn push_compaction_rows( - out: &mut String, - title: &str, - rows: Option<&serde_json::Value>, - render: impl Fn(&serde_json::Value) -> String, -) { - let Some(rows) = rows.and_then(serde_json::Value::as_array) else { - return; - }; - if rows.is_empty() { - return; - } - out.push_str(&format!("\n## {title}\n")); - for row in rows { - let text = render(row); - if !text.trim().is_empty() { - out.push_str(&format!("- {text}\n")); - } - } -} - -fn print_osc52(text: &str) -> Result<()> { - let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - eprint!("\x1b]52;c;{}\x07", encoded); - io::stderr().flush()?; - Ok(()) -} - -fn list_skillplus() -> Result> { - let output = Command::new("skillplus").arg("list").arg("--json").output(); - let Ok(output) = output else { - return Ok(Vec::new()); - }; - if !output.status.success() { - return Ok(Vec::new()); - } - let skills = serde_json::from_slice(&output.stdout)?; - Ok(skills) -} - -fn first_non_empty<'a>(values: impl IntoIterator>) -> Option { - values - .into_iter() - .flatten() - .map(str::trim) - .find(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn default_reasoning_effort(provider: &str, model: &str) -> String { - let p = provider.to_ascii_lowercase(); - let m = model.to_ascii_lowercase(); - if (p == "openai" || p == "openrouter") && (m.starts_with("gpt-5") || m.contains("/gpt-5")) { - "xhigh".to_string() - } else { - String::new() - } -} - -fn effective_bash_mode(value: &str) -> String { - match value { - "auto" | "trusted" => value.to_string(), - _ => "strict".to_string(), - } -} - -fn empty_as_auto(value: &str) -> &str { - if value.is_empty() { - "auto" - } else { - value - } -} - -fn empty_as_none(value: &str) -> &str { - if value.is_empty() { - "none" - } else { - value - } -} - -fn terminal_width() -> usize { - std::env::var("COLUMNS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(88) -} diff --git a/rust/crates/openmelon-tui/src/app/event_tui.rs b/rust/crates/openmelon-tui/src/app/event_tui.rs deleted file mode 100644 index 5dda785..0000000 --- a/rust/crates/openmelon-tui/src/app/event_tui.rs +++ /dev/null @@ -1,2571 +0,0 @@ -use std::collections::VecDeque; -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread; -use std::time::{Duration, Instant}; - -use anyhow::{bail, Context, Result}; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -use super::{ - build_project_system_prompt, effective_bash_mode, empty_as_auto, empty_as_none, - render_compaction_draft, App, -}; -use crate::config::{load_user_config, resolve_provider}; -use crate::image::ImageGenerator; -use crate::llm::{Message, OpenAIClient, Usage}; -use crate::project::{set_project_default, set_project_setting}; -use crate::render::{ - history_rule, render_finish_result, render_markdown, render_plain_transcript, - render_tool_result, render_user_message, tool_call_summary, TranscriptMode, -}; -use crate::runtime::{RunInput, RunResult, Runtime, RuntimeEvent}; -use crate::session::{load_events, Session}; -use crate::tools::{channel_approval_fn, ApprovalDecision, ApprovalRequest, ToolEnv, ToolRegistry}; - -const RESET: &str = "\x1b[0m"; -const BOLD: &str = "\x1b[1m"; -const DIM: &str = "\x1b[2m"; -const RED: &str = "\x1b[31m"; -const GREEN: &str = "\x1b[32m"; -const CYAN: &str = "\x1b[36m"; -const CLEAR: &str = "\x1b[2J\x1b[H"; -const HIDE_CURSOR: &str = "\x1b[?25l"; -const SHOW_CURSOR: &str = "\x1b[?25h"; -const STEADY_BLOCK_CURSOR: &str = "\x1b[2 q"; -const DEFAULT_CURSOR: &str = "\x1b[0 q"; -const IME_COMPOSITION_BUFFER: usize = 18; - -const SLASH_COMMANDS: &[(&str, &str)] = &[ - ("/help", "show this list of commands"), - ("/status", "show project/model status"), - ("/history", "render conversation history"), - ("/clear", "clear in-memory history"), - ("/session", "show current session"), - ("/save", "save history as JSONL"), - ("/copy", "copy transcript via OSC52"), - ("/events", "show recent session events"), - ("/model", "switch LLM model"), - ("/model-image", "switch image model"), - ("/settings", "change settings"), - ("/skill", "apply a skillplus package"), - ("/space", "show creative space summary"), - ("/compact", "print compaction draft"), - ("/exit", "exit"), -]; - -const LLM_PRESETS: &[&str] = &[ - "gpt-5.5", - "gpt-5.4", - "gpt-5.4-mini", - "openai/gpt-5.5", - "anthropic/claude-sonnet-4.5", - "google/gemini-3-pro-preview", -]; - -const IMAGE_PRESETS: &[&str] = &[ - "gpt-image-1", - "openai/gpt-image-1", - "google/gemini-2.5-flash-image-preview", -]; - -const BASH_ROWS: &[(&str, &str, &str)] = &[ - ("strict", "Strict", "Every bash command requires approval."), - ( - "auto", - "Auto-judge", - "Read-only commands can run; write commands require approval.", - ), - ( - "trusted", - "Trusted (dangerous)", - "Run bash commands without asking. Use only in throwaway projects.", - ), -]; - -const REASONING_ROWS: &[(&str, &str, &str)] = &[ - ("", "Auto", "Use OpenMelon's model-aware default."), - ("medium", "Medium", "Balanced reasoning depth."), - ( - "high", - "High", - "Deeper reasoning for planning and tool-heavy work.", - ), - ("xhigh", "XHigh", "Maximum reasoning hint when supported."), -]; - -pub fn run(mut app: App) -> Result<()> { - let mut session = app.create_session("interactive REPL")?; - let mut state = TuiState::new(&app, &session); - state.history = app.initial_history.clone(); - - let mut term = TerminalGuard::enter()?; - state.resize(term.size()); - state.append_launch_history(&app); - state.render(&mut term)?; - let mut input = InputDecoder::default(); - - let mut worker: Option = None; - let mut last_tick = Instant::now(); - loop { - if let Some(handle) = worker.as_mut() { - state.drain_worker(handle, &mut app, &mut session)?; - if state.exit_after_worker { - break; - } - if handle.finished { - worker = None; - if let Some(next) = state.pending_inputs.pop_front() { - state.pending_count = state.pending_inputs.len(); - state.mark_dirty(); - state.submit(next, &mut app, &mut session, &mut worker)?; - } - } - } - - if let Some((req, reply)) = state.approval_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { - state.approval = Some(PendingApproval { - request: req, - reply: Some(reply), - cursor: 0, - scroll: 0, - }); - state.mark_dirty(); - } - - if last_tick.elapsed() >= Duration::from_millis(250) { - state.tick(); - last_tick = Instant::now(); - } - - state.resize(term.size()); - if state.dirty { - state.render(&mut term)?; - } - - let mut should_exit = false; - if input_ready(Duration::from_millis(40))? { - for key in input.read_keys()? { - match key { - Key::None => {} - Key::CtrlD => { - if state.running { - state.exit_after_worker = true; - state.mark_dirty(); - } else { - should_exit = true; - break; - } - } - key => { - if state.handle_key(key, &mut app, &mut session, &mut worker)? { - should_exit = true; - break; - } - } - } - } - } - if should_exit { - break; - } - if state.dirty { - state.render(&mut term)?; - } - } - - term.leave()?; - eprintln!(); - eprintln!("session saved at {}", session.dir.display()); - eprintln!("to resume: openmelon resume {}", session.id); - Ok(()) -} - -struct TuiState { - width: usize, - height: usize, - transcript: Vec, - streaming: String, - scroll: usize, - anchored_bottom: bool, - input: String, - cursor: usize, - input_history: Vec, - history_cursor: Option, - history_draft: String, - palette_visible: bool, - palette_cursor: usize, - pending_inputs: VecDeque, - pending_count: usize, - running: bool, - activity: String, - run_started: Option, - turn_usage: Usage, - last_usage: Usage, - history: Vec, - persisted_up_to: usize, - active_skill: String, - last_ctrl_c: Option, - provider: String, - model: String, - reasoning_effort: String, - image_provider: String, - image_model: String, - bash_mode: String, - approval_tx: mpsc::Sender<(ApprovalRequest, mpsc::Sender)>, - approval_rx: Option)>>, - approval: Option, - exit_after_worker: bool, - header_identity: String, - dirty: bool, - overlay: Overlay, -} - -struct PendingApproval { - request: ApprovalRequest, - reply: Option>, - cursor: usize, - scroll: usize, -} - -struct WorkerHandle { - events_rx: mpsc::Receiver, - done_rx: mpsc::Receiver, - pending_tx: mpsc::Sender, - finished: bool, -} - -struct WorkerDone { - result: Result, -} - -#[derive(Clone)] -enum TranscriptBlock { - Raw(String), - Markdown(String), - Rule(String), - ToolCall { name: String, summary: String }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum Overlay { - None, - ModelSelect { image: bool, cursor: usize }, - ModelCustom { image: bool }, - Settings { cursor: usize }, -} - -#[derive(Debug, Clone, Copy)] -enum ScrollSnap { - Backward, - Forward, -} - -#[derive(Debug, Clone)] -struct TranscriptLine { - text: String, - block_start: bool, -} - -impl TuiState { - fn new(app: &App, session: &Session) -> Self { - let (approval_tx, approval_rx) = mpsc::channel(); - let mut state = Self { - width: 88, - height: 24, - transcript: Vec::new(), - streaming: String::new(), - scroll: 0, - anchored_bottom: true, - input: String::new(), - cursor: 0, - input_history: Vec::new(), - history_cursor: None, - history_draft: String::new(), - palette_visible: false, - palette_cursor: 0, - pending_inputs: VecDeque::new(), - pending_count: 0, - running: false, - activity: String::new(), - run_started: None, - turn_usage: Usage::default(), - last_usage: Usage::default(), - history: Vec::new(), - persisted_up_to: app.initial_history.len(), - active_skill: String::new(), - last_ctrl_c: None, - provider: app.provider.clone(), - model: app.model.clone(), - reasoning_effort: app.reasoning_effort.clone(), - image_provider: app.image_provider.clone(), - image_model: app.image_model.clone(), - bash_mode: effective_bash_mode(&app.workspace.project.settings.bash_permission_mode), - approval_tx, - approval_rx: Some(approval_rx), - approval: None, - exit_after_worker: false, - header_identity: app.workspace.project.name.clone(), - dirty: true, - overlay: Overlay::None, - }; - state.append_raw(format!("{DIM}session {}{RESET}", session.id)); - if let Some(resumed) = &app.resumed_from { - state.append_raw(format!("{DIM}resumed from {resumed}{RESET}")); - } - state.append_raw(format!( - "{DIM}Type a request and press Enter. /help for commands. Esc clears input. Ctrl+C twice exits.{RESET}" - )); - state.append_raw(String::new()); - state - } - - fn append_launch_history(&mut self, app: &App) { - if app.initial_history.is_empty() { - return; - } - self.append_rule(format!( - "prior conversation ({} messages)", - app.initial_history.len() - )); - self.append_rendered_history(&app.initial_history); - self.append_rule("continue below"); - self.append_raw(String::new()); - } - - fn append_rendered_history(&mut self, messages: &[Message]) { - let mut tool_names = std::collections::BTreeMap::::new(); - for msg in messages { - match msg.role { - crate::llm::Role::System => {} - crate::llm::Role::User => { - self.append_raw(render_user_message(&msg.content)); - self.append_raw(String::new()); - } - crate::llm::Role::Assistant => { - if !msg.content.trim().is_empty() { - self.append_markdown(msg.content.clone()); - } - for call in &msg.tool_calls { - if !call.id.is_empty() { - tool_names.insert(call.id.clone(), call.name.clone()); - } - if call.name != "finish" { - self.append_tool_call(&call.name, tool_call_summary(call)); - } - } - } - crate::llm::Role::Tool => { - let name = tool_names - .get(&msg.tool_call_id) - .map(String::as_str) - .unwrap_or(""); - if name == "finish" { - let rendered = - render_finish_result(&msg.content, self.width, TranscriptMode::Styled); - if !rendered.trim().is_empty() { - self.append_raw(rendered); - } - } else { - self.append_raw(render_tool_result( - name, - &msg.content, - TranscriptMode::Styled, - )); - } - self.append_raw(String::new()); - } - } - } - } - - fn resize(&mut self, (width, height): (usize, usize)) { - let width = width.max(20); - let height = height.max(8); - if self.width != width || self.height != height { - self.width = width; - self.height = height; - if self.anchored_bottom { - self.scroll = usize::MAX; - } - self.mark_dirty(); - } - } - - fn tick(&mut self) { - if self.running { - self.mark_dirty(); - } - } - - fn mark_dirty(&mut self) { - self.dirty = true; - } - - fn append_raw(&mut self, text: String) { - self.transcript.push(TranscriptBlock::Raw(text)); - self.follow_bottom(); - self.mark_dirty(); - } - - fn append_rule(&mut self, label: impl Into) { - self.transcript.push(TranscriptBlock::Rule(label.into())); - self.follow_bottom(); - self.mark_dirty(); - } - - fn append_tool_call(&mut self, name: impl Into, summary: impl Into) { - self.transcript.push(TranscriptBlock::ToolCall { - name: name.into(), - summary: summary.into(), - }); - self.follow_bottom(); - self.mark_dirty(); - } - - fn append_markdown(&mut self, text: String) { - if !text.trim().is_empty() { - self.transcript.push(TranscriptBlock::Markdown(text)); - self.follow_bottom(); - self.mark_dirty(); - } - } - - fn append_error(&mut self, text: impl Into) { - self.append_raw(format!("{RED}{}{RESET}", text.into())); - } - - fn follow_bottom(&mut self) { - if self.anchored_bottom { - self.scroll = usize::MAX; - } - } - - fn render(&mut self, term: &mut TerminalGuard) -> Result<()> { - let mut lines = Vec::with_capacity(self.height.max(1)); - let mut cursor_position = None; - - let header = self.header_line(); - lines.push(header); - - let (overlay_lines, overlay_cursor) = self.overlay_lines(); - let palette_lines = if matches!(self.overlay, Overlay::None) { - self.palette_lines() - } else { - Vec::new() - }; - let (input_lines, input_cursor) = if matches!(self.overlay, Overlay::None) { - self.input_lines() - } else { - (Vec::new(), None) - }; - let input_spacer_lines = if matches!(self.overlay, Overlay::None) && !input_lines.is_empty() - { - vec![String::new()] - } else { - Vec::new() - }; - let status_lines = self.status_lines(); - let approval_lines = self.approval_lines(); - let overlay_count = overlay_lines.len() - + palette_lines.len() - + input_lines.len() - + input_spacer_lines.len() - + status_lines.len() - + approval_lines.len(); - let viewport_height = self.height.saturating_sub(1 + overlay_count).max(1); - let transcript_lines = self.transcript_lines(); - let max_scroll = transcript_lines.len().saturating_sub(viewport_height); - if self.scroll == usize::MAX || self.anchored_bottom { - self.scroll = max_scroll; - } else { - self.scroll = self.scroll.min(max_scroll); - self.scroll = snap_scroll_to_block_start(&transcript_lines, self.scroll); - } - let visible = transcript_lines - .iter() - .skip(self.scroll) - .take(viewport_height) - .collect::>(); - let pad_top = if self.anchored_bottom && visible.len() < viewport_height { - viewport_height - visible.len() - } else { - 0 - }; - for _ in 0..pad_top { - lines.push(String::new()); - } - for line in visible { - lines.push(line.text.clone()); - } - let used = pad_top - + transcript_lines - .len() - .saturating_sub(self.scroll) - .min(viewport_height); - for _ in used..viewport_height { - lines.push(String::new()); - } - for line in palette_lines - .iter() - .chain(approval_lines.iter()) - .chain(overlay_lines.iter()) - .chain(input_lines.iter()) - .chain(input_spacer_lines.iter()) - .chain(status_lines.iter()) - { - lines.push(line.clone()); - } - if let Some((row, col)) = input_cursor { - let input_start = 1 - + viewport_height - + palette_lines.len() - + approval_lines.len() - + overlay_lines.len(); - cursor_position = Some((input_start + row, col)); - } else if let Some((row, col)) = overlay_cursor { - let overlay_start = 1 + viewport_height + palette_lines.len() + approval_lines.len(); - cursor_position = Some((overlay_start + row, col)); - } - lines.truncate(self.height.max(1)); - - let mut out = String::new(); - out.push_str(CLEAR); - out.push_str(SHOW_CURSOR); - out.push_str(STEADY_BLOCK_CURSOR); - let draw_width = self.width.saturating_sub(1).max(1); - for (idx, line) in lines.iter().enumerate() { - out.push_str(&fit_line(line, draw_width)); - if idx + 1 < lines.len() { - out.push('\n'); - } - } - if let Some((row, col)) = cursor_position { - let row = row.min(self.height.saturating_sub(1)) + 1; - let col = col.min(draw_width.saturating_sub(1)) + 1; - out.push_str(&format!("\x1b[{row};{col}H")); - } else { - out.push_str(HIDE_CURSOR); - } - term.write_all(out.as_bytes())?; - term.flush()?; - self.dirty = false; - Ok(()) - } - - fn header_line(&self) -> String { - let mut parts = vec![ - format!("{BOLD}openmelon{RESET}"), - self.header_identity.clone(), - self.model.clone(), - empty_as_auto(&self.reasoning_effort).to_string(), - ]; - if !self.image_model.is_empty() { - parts.push(format!("img {}", empty_as_none(&self.image_model))); - } - if self.pending_count > 0 { - parts.push(format!("{} pending", self.pending_count)); - } - parts.join(" · ") - } - - fn status_lines(&self) -> Vec { - let mut left = if self.running { - let elapsed = self - .run_started - .map(|t| format_elapsed(t.elapsed())) - .unwrap_or_default(); - format!( - "{} · {}", - if self.activity.is_empty() { - "Thinking" - } else { - &self.activity - }, - elapsed - ) - } else { - "Ready".to_string() - }; - if self.pending_count > 0 { - left.push_str(&format!(" · {} pending", self.pending_count)); - } - let tokens = if self.running && self.turn_usage.total_tokens > 0 { - format_usage(self.turn_usage) - } else if !self.running && self.last_usage.total_tokens > 0 { - format!("last {}", format_usage(self.last_usage)) - } else { - String::new() - }; - let hints = "esc clear · ctrl+c twice quit · pgup/pgdn scroll"; - let right = if tokens.is_empty() { - hints.to_string() - } else { - format!("{tokens} · {hints}") - }; - vec![join_status_line(&left, &right, self.width)] - } - - fn palette_lines(&self) -> Vec { - if !self.palette_visible { - return Vec::new(); - } - let filtered = self.filtered_commands(); - if filtered.is_empty() { - return vec![format!("{DIM} (no matching commands){RESET}")]; - } - let items = filtered - .into_iter() - .take(8) - .enumerate() - .map(|(idx, (name, help))| { - if idx == self.palette_cursor { - format!("{CYAN}› {BOLD}{name}{RESET} {DIM}{help}{RESET}") - } else { - format!(" {DIM}{name}{RESET}") - } - }) - .collect::>(); - let mut lines = Vec::with_capacity(items.len() + 2); - lines.push(command_rule(self.width)); - lines.extend(items); - lines - } - - fn overlay_lines(&self) -> (Vec, Option<(usize, usize)>) { - match &self.overlay { - Overlay::None => (Vec::new(), None), - Overlay::ModelSelect { image, cursor } => (self.selector_lines(*image, *cursor), None), - Overlay::ModelCustom { image } => { - let title = if *image { - "Custom image model id" - } else { - "Custom LLM model id" - }; - let mut lines = vec![ - format!("{BOLD}{title}{RESET}"), - format!( - "{DIM}Type a provider-specific model id, then Enter. Esc cancels.{RESET}" - ), - String::new(), - ]; - let input_start = lines.len(); - let width = self - .width - .saturating_sub(2 + IME_COMPOSITION_BUFFER) - .max(12); - let (input_lines, cursor) = render_prompt_lines( - if self.input.is_empty() { - format!("{DIM}Model id{RESET}") - } else { - self.input.clone() - }, - &self.input, - self.cursor, - width, - ); - lines.extend(input_lines); - let cursor = cursor.map(|(row, col)| (input_start + row, col)); - (lines, cursor) - } - Overlay::Settings { cursor } => (self.settings_lines(*cursor), None), - } - } - - fn selector_lines(&self, image: bool, cursor: usize) -> Vec { - let rows = selector_rows(image); - let current = if image { - &self.image_model - } else { - &self.model - }; - let mut lines = Vec::new(); - let title = if image { - "Select image model" - } else { - "Select LLM model" - }; - let desc = if image { - "Switch the model used by generate_image. Persists to project.json." - } else { - "Switch the model used by this and future turns. Persists to project.json." - }; - lines.push(format!("{BOLD}{title}{RESET}")); - lines.push(format!("{DIM}{desc}{RESET}")); - lines.push(String::new()); - for (idx, row) in rows.iter().enumerate() { - let marker = if idx == cursor { - format!("{CYAN}›{RESET}") - } else { - " ".to_string() - }; - let num = format!("{}.", idx + 1); - let label = if row.is_empty() { "Custom..." } else { row }; - let check = if !row.is_empty() && current.contains(row) { - " ✓" - } else { - "" - }; - let line = if idx == cursor { - format!("{marker} {CYAN}{num} {label}{check}{RESET}") - } else { - format!("{marker} {num} {label}{check}") - }; - lines.push(line); - } - lines.push(String::new()); - lines.push(format!( - "{DIM}Enter confirm · Esc cancel · 1-N shortcut{RESET}" - )); - lines - } - - fn settings_lines(&self, cursor: usize) -> Vec { - let rows = settings_rows(); - let bash = self.bash_mode.as_str(); - let reasoning = self.reasoning_effort.as_str(); - let mut lines = vec![ - format!("{BOLD}Settings{RESET}"), - format!("{DIM}Persists to project.json.{RESET}"), - String::new(), - ]; - let mut n = 0usize; - for (idx, row) in rows.iter().enumerate() { - match row { - SettingsRow::Section(title) => lines.push(format!("{BOLD}{title}{RESET}")), - SettingsRow::Choice { - kind, - value, - title, - desc, - } => { - n += 1; - let active = match *kind { - "bash" => value == &bash, - "reasoning" => value == &reasoning, - _ => false, - }; - let marker = if idx == cursor { - format!("{CYAN}›{RESET}") - } else { - " ".to_string() - }; - let check = if active { " ✓" } else { "" }; - let title = if idx == cursor { - format!("{CYAN}{n}. {title}{check}{RESET}") - } else { - format!("{n}. {title}{check}") - }; - lines.push(format!("{marker} {title}")); - lines.push(format!(" {DIM}{desc}{RESET}")); - } - } - } - lines.push(String::new()); - lines.push(format!( - "{DIM}Enter set · Esc close · ↑/↓ select · 1-7 shortcut{RESET}" - )); - lines - } - - fn approval_lines(&self) -> Vec { - let Some(approval) = &self.approval else { - return Vec::new(); - }; - let max_body = (self.height / 3).clamp(4, 10); - let mut body = vec![format!("Reason: {}", approval.request.description)]; - body.extend(wrap_text( - &format!("Command: {}", approval.request.command), - self.width.saturating_sub(4), - )); - if !approval.request.binary.is_empty() { - body.push(format!("Binary: {}", approval.request.binary)); - } - let max_scroll = body.len().saturating_sub(max_body); - let scroll = approval.scroll.min(max_scroll); - let mut lines = vec![ - history_rule("approval required", self.width), - format!("{BOLD}Do you want to proceed?{RESET}"), - ]; - for line in body.iter().skip(scroll).take(max_body) { - lines.push(format!(" {line}")); - } - if max_scroll > 0 { - lines.push(format!( - "{DIM} showing {}-{} of {} · PgUp/PgDn scroll{RESET}", - scroll + 1, - (scroll + max_body).min(body.len()), - body.len() - )); - } - let opts = if approval.request.binary.is_empty() { - ["Yes", "No", ""] - } else { - ["Yes", "Always", "No"] - }; - let row = opts - .iter() - .filter(|item| !item.is_empty()) - .enumerate() - .map(|(idx, item)| { - if idx == approval.cursor { - format!("{CYAN}[{item}]{RESET}") - } else { - format!("[{item}]") - } - }) - .collect::>() - .join(" "); - lines.push(format!(" {row}")); - lines.push(history_rule("end approval", self.width)); - lines - } - - fn input_lines(&self) -> (Vec, Option<(usize, usize)>) { - let width = self - .width - .saturating_sub(4 + IME_COMPOSITION_BUFFER) - .max(12); - let text = if self.input.is_empty() { - format!("{DIM}Ask OpenMelon{RESET}") - } else { - self.input.clone() - }; - render_prompt_lines(text, &self.input, self.cursor, width) - } - - fn transcript_lines(&self) -> Vec { - let mut out = Vec::new(); - for block in &self.transcript { - match block { - TranscriptBlock::Raw(text) => { - if text.is_empty() { - push_transcript_block(&mut out, vec![String::new()]); - } else { - let mut lines = Vec::new(); - for line in text.lines() { - let line = compact_artifact_display_line(line); - lines.extend(wrap_text_with_indent(&line, self.width)); - } - push_transcript_block(&mut out, lines); - } - } - TranscriptBlock::Markdown(text) => { - let rendered = render_markdown(text, self.width); - let mut lines = Vec::new(); - for line in rendered.lines() { - lines.extend(wrap_text_with_indent(&format!(" {line}"), self.width)); - } - push_transcript_block(&mut out, lines); - } - TranscriptBlock::Rule(label) => { - push_transcript_block(&mut out, vec![history_rule(label, self.width)]); - } - TranscriptBlock::ToolCall { name, summary } => { - push_transcript_block( - &mut out, - render_tool_call_lines(name, summary, self.width), - ); - } - } - } - if !self.streaming.trim().is_empty() { - let rendered = render_markdown(&self.streaming, self.width); - let mut lines = Vec::new(); - for line in rendered.lines() { - lines.extend(wrap_text_with_indent(&format!(" {line}"), self.width)); - } - push_transcript_block(&mut out, lines); - } - out - } - - fn handle_key( - &mut self, - key: Key, - app: &mut App, - session: &mut Session, - worker: &mut Option, - ) -> Result { - if self.approval.is_some() { - self.handle_approval_key(key); - self.mark_dirty(); - return Ok(false); - } - if !matches!(self.overlay, Overlay::None) { - return self.handle_overlay_key(key, app); - } - match key { - Key::CtrlC => { - if !self.input.is_empty() { - self.record_history(self.input.clone()); - self.input.clear(); - self.cursor = 0; - self.palette_visible = false; - self.mark_dirty(); - return Ok(false); - } - if self - .last_ctrl_c - .is_some_and(|t| t.elapsed() < Duration::from_secs(2)) - { - return Ok(true); - } - self.last_ctrl_c = Some(Instant::now()); - self.append_raw(format!("{DIM}Press Ctrl+C again within 2s to quit.{RESET}")); - } - Key::Esc => { - if self.palette_visible { - self.palette_visible = false; - } else if !self.input.is_empty() { - self.record_history(self.input.clone()); - self.input.clear(); - self.cursor = 0; - } - } - Key::Enter => { - if self.palette_visible && !self.input.contains(char::is_whitespace) { - if let Some((name, _)) = - self.filtered_commands().get(self.palette_cursor).copied() - { - self.record_history(name.to_string()); - self.input.clear(); - self.cursor = 0; - self.palette_visible = false; - if self.running { - self.queue_pending(name.to_string(), session, worker.as_ref()); - } else { - self.submit(name.to_string(), app, session, worker)?; - } - self.mark_dirty(); - return Ok(false); - } - } - let text = self.input.trim().to_string(); - if text.is_empty() { - return Ok(false); - } - self.record_history(text.clone()); - self.input.clear(); - self.cursor = 0; - self.palette_visible = false; - if self.running { - self.queue_pending(text, session, worker.as_ref()); - } else { - self.submit(text, app, session, worker)?; - } - } - Key::ShiftEnter | Key::AltEnter | Key::CtrlJ => self.insert_char('\n'), - Key::Backspace => self.backspace(), - Key::Delete => self.delete(), - Key::Left => self.move_left(), - Key::Right => self.move_right(), - Key::Home => self.cursor = 0, - Key::End if self.input.is_empty() => { - self.anchored_bottom = true; - self.scroll = usize::MAX; - } - Key::End => self.cursor = self.input.len(), - Key::Up if self.palette_visible => { - if self.palette_cursor > 0 { - self.palette_cursor -= 1; - } - } - Key::Down if self.palette_visible => { - let len = self.filtered_commands().len(); - if self.palette_cursor + 1 < len { - self.palette_cursor += 1; - } - } - Key::Tab if self.palette_visible => { - if let Some((name, _)) = self.filtered_commands().get(self.palette_cursor).copied() - { - self.input = format!("{name} "); - self.cursor = self.input.len(); - self.palette_visible = false; - } - } - Key::Up => self.history_prev(), - Key::Down => self.history_next(), - Key::PageUp => { - self.anchored_bottom = false; - let target = self.scroll.saturating_sub((self.height / 2).max(1)); - self.scroll = self.snap_scroll(target, ScrollSnap::Backward); - } - Key::PageDown => { - self.anchored_bottom = false; - let target = self.scroll.saturating_add((self.height / 2).max(1)); - self.scroll = self.snap_scroll(target, ScrollSnap::Forward); - } - Key::Char(ch) => { - self.insert_char(ch); - self.refresh_palette(); - } - Key::Paste(text) => { - for ch in text.chars() { - self.insert_char(ch); - } - self.refresh_palette(); - } - _ => {} - } - self.mark_dirty(); - Ok(false) - } - - fn handle_approval_key(&mut self, key: Key) { - let Some(approval) = self.approval.as_mut() else { - return; - }; - let request_binary_empty = approval.request.binary.is_empty(); - let max = if approval.request.binary.is_empty() { - 1 - } else { - 2 - }; - match key { - Key::Left | Key::Up => approval.cursor = approval.cursor.saturating_sub(1), - Key::Right | Key::Down | Key::Tab => approval.cursor = (approval.cursor + 1).min(max), - Key::PageUp => approval.scroll = approval.scroll.saturating_sub(3), - Key::PageDown => approval.scroll = approval.scroll.saturating_add(3), - Key::Esc | Key::CtrlC => self.resolve_approval(ApprovalDecision::No), - Key::Char('y') | Key::Char('Y') => self.resolve_approval(ApprovalDecision::Yes), - Key::Char('a') | Key::Char('A') if !request_binary_empty => { - self.resolve_approval(ApprovalDecision::Always) - } - Key::Enter => { - let decision = match approval.cursor { - 0 => ApprovalDecision::Yes, - 1 if !request_binary_empty => ApprovalDecision::Always, - _ => ApprovalDecision::No, - }; - self.resolve_approval(decision); - } - _ => {} - } - self.mark_dirty(); - } - - fn resolve_approval(&mut self, decision: ApprovalDecision) { - if let Some(mut approval) = self.approval.take() { - if let Some(reply) = approval.reply.take() { - let _ = reply.send(decision); - } - } - } - - fn handle_overlay_key(&mut self, key: Key, app: &mut App) -> Result { - let overlay = self.overlay.clone(); - match overlay { - Overlay::None => {} - Overlay::ModelSelect { image, mut cursor } => { - let rows = selector_rows(image); - match key { - Key::Esc | Key::CtrlC => self.close_overlay(), - Key::Up | Key::Char('k') => { - cursor = cursor.saturating_sub(1); - self.overlay = Overlay::ModelSelect { image, cursor }; - } - Key::Down | Key::Char('j') => { - if cursor + 1 < rows.len() { - cursor += 1; - } - self.overlay = Overlay::ModelSelect { image, cursor }; - } - Key::Enter => { - let picked = rows.get(cursor).copied().unwrap_or(""); - if picked.is_empty() { - self.input.clear(); - self.cursor = 0; - self.overlay = Overlay::ModelCustom { image }; - } else { - self.apply_model_pick(app, image, picked)?; - self.close_overlay(); - } - } - Key::Char(ch) if ch.is_ascii_digit() && ch != '0' => { - let idx = ch as usize - '1' as usize; - if idx < rows.len() { - let picked = rows[idx]; - if picked.is_empty() { - self.input.clear(); - self.cursor = 0; - self.overlay = Overlay::ModelCustom { image }; - } else { - self.apply_model_pick(app, image, picked)?; - self.close_overlay(); - } - } - } - _ => {} - } - } - Overlay::ModelCustom { image } => match key { - Key::Esc | Key::CtrlC => self.close_overlay(), - Key::Enter => { - let value = self.input.trim().to_string(); - if !value.is_empty() { - self.apply_model_pick(app, image, &value)?; - self.close_overlay(); - } - } - Key::Backspace => self.backspace(), - Key::Delete => self.delete(), - Key::Left => self.move_left(), - Key::Right => self.move_right(), - Key::Home => self.cursor = 0, - Key::End => self.cursor = self.input.len(), - Key::Char(ch) => self.insert_char(ch), - Key::Paste(text) => { - for ch in text.chars() { - self.insert_char(ch); - } - } - _ => {} - }, - Overlay::Settings { mut cursor } => { - let rows = settings_rows(); - match key { - Key::Esc | Key::CtrlC => self.close_overlay(), - Key::Up | Key::Char('k') => { - cursor = previous_settings_cursor(&rows, cursor); - self.overlay = Overlay::Settings { cursor }; - } - Key::Down | Key::Char('j') => { - cursor = next_settings_cursor(&rows, cursor); - self.overlay = Overlay::Settings { cursor }; - } - Key::Enter => { - self.apply_settings_pick(app, cursor)?; - self.close_overlay(); - } - Key::Char(ch) if ch.is_ascii_digit() && ch != '0' => { - if let Some(idx) = settings_number_index(&rows, ch as usize - '0' as usize) - { - self.apply_settings_pick(app, idx)?; - self.close_overlay(); - } - } - _ => {} - } - } - } - self.mark_dirty(); - Ok(false) - } - - fn close_overlay(&mut self) { - self.overlay = Overlay::None; - self.input.clear(); - self.cursor = 0; - self.palette_visible = false; - } - - fn open_model_selector(&mut self, image: bool) { - let rows = selector_rows(image); - let current = if image { - &self.image_model - } else { - &self.model - }; - let cursor = rows - .iter() - .position(|row| !row.is_empty() && row == current) - .unwrap_or(0); - self.overlay = Overlay::ModelSelect { image, cursor }; - self.palette_visible = false; - } - - fn open_settings(&mut self) { - let rows = settings_rows(); - let cursor = rows - .iter() - .position(|row| match row { - SettingsRow::Choice { kind, value, .. } if *kind == "bash" => { - value == &self.bash_mode - } - SettingsRow::Choice { kind, value, .. } if *kind == "reasoning" => { - value == &self.reasoning_effort - } - _ => false, - }) - .unwrap_or(1); - self.overlay = Overlay::Settings { cursor }; - self.palette_visible = false; - } - - fn apply_model_pick(&mut self, app: &mut App, image: bool, picked: &str) -> Result<()> { - if image { - if picked.eq_ignore_ascii_case("off") || picked.eq_ignore_ascii_case("none") { - app.image_provider.clear(); - app.image_model.clear(); - } else { - if app.image_provider.is_empty() { - app.image_provider = app.provider.clone(); - } - app.image_model = picked.to_string(); - } - set_project_default(&app.workspace.root, "image_provider", &app.image_provider)?; - set_project_default(&app.workspace.root, "image_model", &app.image_model)?; - self.image_provider = app.image_provider.clone(); - self.image_model = app.image_model.clone(); - self.append_raw(format!( - "{DIM}(image model: {}:{}){RESET}", - empty_as_none(&self.image_provider), - empty_as_none(&self.image_model) - )); - return Ok(()); - } - app.model = picked.to_string(); - set_project_default(&app.workspace.root, "llm_model", &app.model)?; - self.model = app.model.clone(); - self.append_raw(format!( - "{DIM}(LLM: {}:{}){RESET}", - self.provider, self.model - )); - Ok(()) - } - - fn apply_settings_pick(&mut self, app: &mut App, idx: usize) -> Result<()> { - let rows = settings_rows(); - let Some(row) = rows.get(idx) else { - return Ok(()); - }; - match row { - SettingsRow::Choice { kind, value, .. } if *kind == "bash" => { - set_project_setting(&app.workspace.root, "bash_permission_mode", value)?; - app.workspace.project.settings.bash_permission_mode = value.to_string(); - self.bash_mode = effective_bash_mode(value); - } - SettingsRow::Choice { kind, value, .. } if *kind == "reasoning" => { - set_project_setting(&app.workspace.root, "reasoning_effort", value)?; - app.reasoning_effort = value.to_string(); - self.reasoning_effort = value.to_string(); - } - _ => return Ok(()), - } - self.append_raw(format!( - "{DIM}(settings: bash={} reasoning={}){RESET}", - self.bash_mode, - empty_as_auto(&self.reasoning_effort) - )); - Ok(()) - } - - fn submit( - &mut self, - text: String, - app: &mut App, - session: &mut Session, - worker: &mut Option, - ) -> Result<()> { - if text.starts_with('/') { - if self.handle_slash(&text, app, session, worker)? { - self.exit_after_worker = true; - } - return Ok(()); - } - let user_text = if self.active_skill.is_empty() { - text - } else { - let skill = std::mem::take(&mut self.active_skill); - format!("Apply the skill {skill:?} to this request: first call compile_skill with skill={skill:?} (BARE slug, no 'skillplus:' prefix) to fetch the package's prompt + output schema, then proceed.\n\n{text}") - }; - self.append_raw(render_user_message(&user_text)); - self.append_raw(String::new()); - session.append_prompt("user", &user_text)?; - let handle = spawn_runtime( - app, - session, - user_text, - self.history.clone(), - self.approval_tx.clone(), - )?; - self.running = true; - self.run_started = Some(Instant::now()); - self.activity = format!("Sending to {}", app.model); - *worker = Some(handle); - self.mark_dirty(); - Ok(()) - } - - fn handle_slash( - &mut self, - text: &str, - app: &mut App, - session: &mut Session, - _worker: &mut Option, - ) -> Result { - let parts = text.split_whitespace().collect::>(); - match parts.first().copied().unwrap_or_default() { - "/exit" | "/quit" | "/q" => Ok(true), - "/help" => { - self.append_markdown( - SLASH_COMMANDS - .iter() - .map(|(name, help)| format!("- `{name}` {help}")) - .collect::>() - .join("\n"), - ); - Ok(false) - } - "/status" => { - self.append_raw(format!( - "project: {} ({})", - app.workspace.project.name, app.workspace.project.id - )); - self.append_raw(format!( - "model: {}:{} reasoning={}", - app.provider, - app.model, - empty_as_auto(&app.reasoning_effort) - )); - self.append_raw(format!( - "image: {}:{}", - empty_as_none(&app.image_provider), - empty_as_none(&app.image_model) - )); - self.append_raw(format!( - "outputs: {}", - app.workspace.outputs_dir().display() - )); - Ok(false) - } - "/history" => { - let history = self.history.clone(); - self.append_rendered_history(&history); - Ok(false) - } - "/clear" => { - self.history.clear(); - self.persisted_up_to = 0; - self.append_raw("history cleared".to_string()); - Ok(false) - } - "/session" => { - self.append_raw(format!("{}", session.dir.display())); - Ok(false) - } - "/save" => { - let Some(path) = parts.get(1) else { - bail!("/save: usage /save "); - }; - let mut file = std::fs::File::create(path)?; - for message in &self.history { - serde_json::to_writer(&mut file, message)?; - file.write_all(b"\n")?; - } - self.append_raw(format!("saved {} messages -> {path}", self.history.len())); - Ok(false) - } - "/copy" => { - let text = render_plain_transcript(&self.history, self.width); - if text.trim().is_empty() { - self.append_raw("nothing to copy".to_string()); - } else { - print_osc52(&text)?; - self.append_raw(format!( - "copied transcript ({} chars)", - text.chars().count() - )); - } - Ok(false) - } - "/events" => { - for event in load_events(&session.dir, 20)? { - self.append_raw(serde_json::to_string(&event)?); - } - Ok(false) - } - "/model" => { - if let Some(model) = parts.get(1) { - self.apply_model_pick(app, false, model)?; - } else { - self.open_model_selector(false); - } - Ok(false) - } - "/model-image" => { - if parts.len() >= 3 { - app.image_provider = parts[1].to_string(); - self.apply_model_pick(app, true, parts[2])?; - } else if let Some(model) = parts.get(1) { - self.apply_model_pick(app, true, model)?; - } else { - self.open_model_selector(true); - } - Ok(false) - } - "/settings" => { - match (parts.get(1).copied(), parts.get(2).copied()) { - (Some("bash"), Some(mode)) if matches!(mode, "strict" | "auto" | "trusted") => { - set_project_setting(&app.workspace.root, "bash_permission_mode", mode)?; - app.workspace.project.settings.bash_permission_mode = mode.to_string(); - self.bash_mode = effective_bash_mode(mode); - self.append_raw(format!("bash_permission_mode: {mode}")); - } - (Some("reasoning"), Some(effort)) - if matches!(effort, "auto" | "medium" | "high" | "xhigh") => - { - let value = if effort == "auto" { "" } else { effort }; - set_project_setting(&app.workspace.root, "reasoning_effort", value)?; - app.reasoning_effort = value.to_string(); - self.reasoning_effort = value.to_string(); - self.append_raw(format!( - "reasoning_effort: {}", - empty_as_auto(&app.reasoning_effort) - )); - } - (None, None) => { - self.open_settings(); - } - _ => { - self.append_error( - "/settings: use /settings, /settings bash , or /settings reasoning ", - ); - } - } - Ok(false) - } - "/skill" => { - if parts.get(1).is_none() { - self.append_raw("usage: /skill or /skill clear".to_string()); - } else if matches!(parts[1], "clear" | "off" | "none") { - self.active_skill.clear(); - self.append_raw("skill cleared".to_string()); - } else { - self.active_skill = parts[1].to_string(); - self.append_raw(format!( - "skill: {} applies to your next message", - self.active_skill - )); - } - Ok(false) - } - "/space" => { - let Some(space_id) = parts.get(1) else { - bail!("/space: usage /space "); - }; - let env = app.tool_env(Some(session), &session.id); - let packet = ToolRegistry::standard(&env).dispatch( - &env, - "get_context_packet", - serde_json::json!({"space_id": space_id}), - )?; - self.append_markdown(render_compaction_draft(&packet)); - Ok(false) - } - "/compact" => { - let Some(space_id) = parts.get(1) else { - bail!("/compact: usage /compact "); - }; - let env = app.tool_env(Some(session), &session.id); - let packet = ToolRegistry::standard(&env).dispatch( - &env, - "get_context_packet", - serde_json::json!({"space_id": space_id}), - )?; - self.append_markdown(render_compaction_draft(&packet)); - Ok(false) - } - other => { - self.append_error(format!("unknown command: {other}")); - Ok(false) - } - } - } - - fn drain_worker( - &mut self, - handle: &mut WorkerHandle, - _app: &mut App, - session: &mut Session, - ) -> Result<()> { - while let Ok(event) = handle.events_rx.try_recv() { - match event { - RuntimeEvent::TurnStart { step } => { - self.activity = format!("Thinking step {step}"); - if step == 1 { - self.turn_usage = Usage::default(); - } - self.mark_dirty(); - } - RuntimeEvent::TextDelta(delta) => { - self.activity = "Streaming response".to_string(); - self.streaming.push_str(&delta); - self.follow_bottom(); - self.mark_dirty(); - } - RuntimeEvent::ToolCall(call) => { - self.flush_streaming(); - self.activity = format!("Calling {}", call.name); - self.append_tool_call(call.name.clone(), tool_call_summary(&call)); - self.mark_dirty(); - } - RuntimeEvent::ToolResult { - tool_name, - content, - error, - } => { - self.activity = format!("Got {tool_name} result"); - if tool_name == "finish" { - let rendered = - render_finish_result(&content, self.width, TranscriptMode::Styled); - if !rendered.trim().is_empty() { - self.append_raw(rendered); - } - } else { - self.append_raw(render_tool_result( - &tool_name, - &content, - TranscriptMode::Styled, - )); - } - if let Some(err) = error { - self.append_error(format!("error: {err}")); - } - self.append_raw(String::new()); - self.mark_dirty(); - } - RuntimeEvent::TurnEnd { - step, - finish, - usage, - } => { - self.flush_streaming(); - self.activity = format!("Turn {step} ended ({finish:?})"); - add_usage(&mut self.turn_usage, usage); - self.last_usage = self.turn_usage; - self.mark_dirty(); - } - RuntimeEvent::QueuedInputApplied { count } => { - for _ in 0..count { - let _ = self.pending_inputs.pop_front(); - } - self.pending_count = self.pending_inputs.len(); - self.mark_dirty(); - } - } - } - - if let Ok(done) = handle.done_rx.try_recv() { - handle.finished = true; - self.running = false; - self.activity.clear(); - match done.result { - Ok(result) => { - let old_len = self.history.len(); - self.history = result.messages; - if self.persisted_up_to < self.history.len() { - session.append_messages(&self.history[self.persisted_up_to..])?; - self.persisted_up_to = self.history.len(); - } else if old_len > self.history.len() { - self.persisted_up_to = self.history.len(); - } - session.write_summary( - &result.finish_summary, - &result.finish_artifacts, - result.finished, - )?; - if result.finished { - self.flush_streaming(); - } - let _ = handle; - } - Err(err) => self.append_error(format!("error: {err}")), - } - self.mark_dirty(); - } - Ok(()) - } - - fn queue_pending(&mut self, text: String, session: &Session, worker: Option<&WorkerHandle>) { - self.pending_inputs.push_back(text.clone()); - self.pending_count = self.pending_inputs.len(); - if let Some(worker) = worker { - let _ = worker.pending_tx.send(text.clone()); - } - let _ = session.append_prompt("pending", &text); - self.append_raw(format!( - "{DIM}queued for next model call:{RESET}\n > {text}" - )); - self.anchored_bottom = true; - } - - fn flush_streaming(&mut self) { - if self.streaming.trim().is_empty() { - self.streaming.clear(); - return; - } - let text = std::mem::take(&mut self.streaming); - self.append_markdown(text); - } - - fn record_history(&mut self, text: String) { - if self.input_history.last() != Some(&text) { - self.input_history.push(text); - } - self.history_cursor = None; - self.history_draft.clear(); - } - - fn history_prev(&mut self) { - if self.input_history.is_empty() || self.input.contains('\n') { - return; - } - match self.history_cursor { - None => { - self.history_draft = self.input.clone(); - self.history_cursor = Some(self.input_history.len() - 1); - } - Some(0) => {} - Some(idx) => self.history_cursor = Some(idx - 1), - } - self.apply_history_cursor(); - } - - fn history_next(&mut self) { - let Some(idx) = self.history_cursor else { - return; - }; - if idx + 1 >= self.input_history.len() { - self.history_cursor = None; - self.input = self.history_draft.clone(); - self.cursor = self.input.len(); - return; - } - self.history_cursor = Some(idx + 1); - self.apply_history_cursor(); - } - - fn apply_history_cursor(&mut self) { - if let Some(idx) = self.history_cursor { - self.input = self.input_history.get(idx).cloned().unwrap_or_default(); - self.cursor = self.input.len(); - } - } - - fn refresh_palette(&mut self) { - self.palette_visible = self.input.starts_with('/') - && !self.input[1..].contains(char::is_whitespace) - && !self.running; - if self.palette_cursor >= self.filtered_commands().len() { - self.palette_cursor = 0; - } - } - - fn filtered_commands(&self) -> Vec<(&'static str, &'static str)> { - if self.input.trim() == "/" { - return SLASH_COMMANDS.to_vec(); - } - SLASH_COMMANDS - .iter() - .copied() - .filter(|(name, _)| name.starts_with(self.input.trim())) - .collect() - } - - fn insert_char(&mut self, ch: char) { - self.input.insert(self.cursor, ch); - self.cursor += ch.len_utf8(); - } - - fn backspace(&mut self) { - if self.cursor == 0 { - return; - } - if let Some((idx, _)) = self.input[..self.cursor].char_indices().last() { - self.input.drain(idx..self.cursor); - self.cursor = idx; - } - self.refresh_palette(); - } - - fn delete(&mut self) { - if self.cursor >= self.input.len() { - return; - } - let next = self.input[self.cursor..] - .char_indices() - .nth(1) - .map(|(idx, _)| self.cursor + idx) - .unwrap_or(self.input.len()); - self.input.drain(self.cursor..next); - self.refresh_palette(); - } - - fn move_left(&mut self) { - if self.cursor == 0 { - return; - } - if let Some((idx, _)) = self.input[..self.cursor].char_indices().last() { - self.cursor = idx; - } - } - - fn move_right(&mut self) { - if self.cursor >= self.input.len() { - return; - } - self.cursor += self.input[self.cursor..] - .chars() - .next() - .map(char::len_utf8) - .unwrap_or(0); - } - - fn snap_scroll(&self, target: usize, direction: ScrollSnap) -> usize { - let lines = self.transcript_lines(); - match direction { - ScrollSnap::Backward => snap_scroll_to_block_start(&lines, target), - ScrollSnap::Forward => snap_scroll_to_next_block_start(&lines, target), - } - } -} - -fn spawn_runtime( - app: &App, - session: &Session, - prompt: String, - history: Vec, - approval_tx: mpsc::Sender<(ApprovalRequest, mpsc::Sender)>, -) -> Result { - let user_config = load_user_config()?; - let llm_res = resolve_provider( - &app.workspace.root, - &app.workspace.project, - &user_config, - &app.provider, - )?; - let llm = OpenAIClient::new( - &app.provider, - llm_res.api_key, - super::first_non_empty([Some(app.base_url.as_str()), Some(llm_res.base_url.as_str())]) - .unwrap_or_default(), - app.model.clone(), - )?; - let image = build_image_generator(app, &user_config).ok(); - let tool_env = ToolEnv { - workspace: app.workspace.clone(), - session_id: session.id.clone(), - session_dir: session.dir.clone(), - image, - bash_mode: effective_bash_mode(&app.workspace.project.settings.bash_permission_mode), - allowed_bash: app.allowed_bash.clone(), - approve_bash: Some(channel_approval_fn(approval_tx)), - }; - let registry = ToolRegistry::standard(&tool_env); - let system_prompt = build_project_system_prompt(&app.workspace, ®istry.names()); - let (events_tx, events_rx) = mpsc::channel(); - let (done_tx, done_rx) = mpsc::channel(); - let (pending_tx, pending_rx_raw) = mpsc::channel::(); - let pending_rx = Arc::new(Mutex::new(pending_rx_raw)); - let pending_for_worker = pending_rx.clone(); - let mut session = session.fork_writer()?; - session.set_runtime_info(&app.provider, &app.model)?; - let max_steps = app.options.max_steps; - let reasoning = app.reasoning_effort.clone(); - thread::spawn(move || { - let mut runtime = Runtime { - llm, - registry, - env: tool_env, - max_steps, - reasoning_effort: reasoning, - drain_user_input: Some(Box::new(move || { - let mut out = Vec::new(); - let Ok(rx) = pending_for_worker.lock() else { - return out; - }; - while let Ok(text) = rx.try_recv() { - out.push(text); - } - out - })), - events: Some(events_tx), - }; - let result = runtime - .run( - RunInput { - system_prompt, - user_input: prompt, - history, - }, - &mut session, - ) - .map_err(|err| err.to_string()); - let _ = done_tx.send(WorkerDone { result }); - }); - Ok(WorkerHandle { - events_rx, - done_rx, - pending_tx, - finished: false, - }) -} - -fn build_image_generator( - app: &App, - user_config: &crate::config::UserConfig, -) -> Result { - if app.image_provider.trim().is_empty() || app.image_model.trim().is_empty() { - bail!("image generation is not configured"); - } - let resolved = resolve_provider( - &app.workspace.root, - &app.workspace.project, - user_config, - &app.image_provider, - )?; - ImageGenerator::new( - &app.image_provider, - resolved.api_key, - super::first_non_empty([ - Some(app.image_base_url.as_str()), - Some(resolved.base_url.as_str()), - ]) - .unwrap_or_default(), - app.image_model.clone(), - ) -} - -#[derive(Debug, Clone)] -enum Key { - None, - Char(char), - Paste(String), - Enter, - ShiftEnter, - AltEnter, - CtrlJ, - CtrlC, - CtrlD, - Esc, - Backspace, - Delete, - Tab, - Up, - Down, - Left, - Right, - Home, - End, - PageUp, - PageDown, -} - -struct TerminalGuard { - original: libc::termios, - stdout: io::Stdout, - left: bool, -} - -#[derive(Default)] -struct InputDecoder { - buffer: Vec, -} - -#[derive(Debug, Clone)] -enum SettingsRow { - Section(&'static str), - Choice { - kind: &'static str, - value: &'static str, - title: &'static str, - desc: &'static str, - }, -} - -fn selector_rows(image: bool) -> Vec<&'static str> { - let presets = if image { IMAGE_PRESETS } else { LLM_PRESETS }; - let mut rows = presets.to_vec(); - if image { - rows.push("off"); - } - rows.push(""); - rows -} - -fn settings_rows() -> Vec { - let mut rows = Vec::new(); - rows.push(SettingsRow::Section("Bash permissions")); - rows.extend( - BASH_ROWS - .iter() - .map(|(value, title, desc)| SettingsRow::Choice { - kind: "bash", - value, - title, - desc, - }), - ); - rows.push(SettingsRow::Section("Reasoning effort")); - rows.extend( - REASONING_ROWS - .iter() - .map(|(value, title, desc)| SettingsRow::Choice { - kind: "reasoning", - value, - title, - desc, - }), - ); - rows -} - -fn previous_settings_cursor(rows: &[SettingsRow], mut cursor: usize) -> usize { - if rows.is_empty() { - return 0; - } - cursor = cursor.saturating_sub(1); - while cursor > 0 && matches!(rows[cursor], SettingsRow::Section(_)) { - cursor -= 1; - } - if matches!(rows[cursor], SettingsRow::Section(_)) { - next_settings_cursor(rows, cursor) - } else { - cursor - } -} - -fn next_settings_cursor(rows: &[SettingsRow], mut cursor: usize) -> usize { - if rows.is_empty() { - return 0; - } - cursor = (cursor + 1).min(rows.len() - 1); - while cursor + 1 < rows.len() && matches!(rows[cursor], SettingsRow::Section(_)) { - cursor += 1; - } - cursor -} - -fn settings_number_index(rows: &[SettingsRow], number: usize) -> Option { - if number == 0 { - return None; - } - let mut seen = 0usize; - for (idx, row) in rows.iter().enumerate() { - if matches!(row, SettingsRow::Choice { .. }) { - seen += 1; - if seen == number { - return Some(idx); - } - } - } - None -} - -impl InputDecoder { - fn read_keys(&mut self) -> Result> { - let mut bytes = [0u8; 512]; - let n = io::stdin().read(&mut bytes)?; - if n == 0 { - return Ok(vec![Key::None]); - } - self.buffer.extend_from_slice(&bytes[..n]); - Ok(self.drain_keys()) - } - - fn drain_keys(&mut self) -> Vec { - let mut keys = Vec::new(); - while !self.buffer.is_empty() { - if let Some((key, consumed)) = parse_one_key(&self.buffer) { - self.buffer.drain(..consumed); - keys.push(key); - continue; - } - match std::str::from_utf8(&self.buffer) { - Ok(text) => { - let consumed = push_text_keys(text, &mut keys); - self.buffer.drain(..consumed); - if consumed == 0 { - self.buffer.clear(); - } - } - Err(err) if err.valid_up_to() > 0 => { - let valid = err.valid_up_to(); - let text = String::from_utf8_lossy(&self.buffer[..valid]); - let consumed = push_text_keys(&text, &mut keys).min(valid); - self.buffer.drain(..consumed); - if consumed == 0 { - self.buffer.drain(..valid); - } - } - Err(err) if err.error_len().is_some() => { - self.buffer - .drain(..err.valid_up_to() + err.error_len().unwrap_or(1)); - } - Err(_) => break, - } - } - keys - } -} - -fn push_text_keys(text: &str, keys: &mut Vec) -> usize { - let mut consumed = 0usize; - for ch in text.chars() { - if ch == '\x1b' - || matches!( - ch, - '\u{0003}' | '\u{0004}' | '\t' | '\n' | '\r' | '\u{007f}' | '\u{0008}' - ) - { - break; - } - consumed += ch.len_utf8(); - if !ch.is_control() { - keys.push(Key::Char(ch)); - } - } - consumed -} - -impl TerminalGuard { - fn enter() -> Result { - let fd = libc::STDIN_FILENO; - if unsafe { libc::isatty(fd) } != 1 { - bail!("openmelon-rust interactive mode requires a TTY; run it from a terminal, or use `openmelon-rust run ` / `openmelon-rust --help`"); - } - let mut original = unsafe { std::mem::zeroed::() }; - if unsafe { libc::tcgetattr(fd, &mut original) } != 0 { - return Err(io::Error::last_os_error()).context("tcgetattr"); - } - let mut raw = original; - raw.c_lflag &= !(libc::ECHO | libc::ICANON | libc::ISIG | libc::IEXTEN); - raw.c_iflag &= !(libc::IXON | libc::ICRNL | libc::BRKINT | libc::INPCK | libc::ISTRIP); - raw.c_oflag &= !libc::OPOST; - raw.c_cflag |= libc::CS8; - raw.c_cc[libc::VMIN] = 0; - raw.c_cc[libc::VTIME] = 1; - if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 { - return Err(io::Error::last_os_error()).context("tcsetattr raw"); - } - let mut stdout = io::stdout(); - write!(stdout, "{HIDE_CURSOR}")?; - stdout.flush()?; - Ok(Self { - original, - stdout, - left: false, - }) - } - - fn leave(&mut self) -> Result<()> { - if self.left { - return Ok(()); - } - write!(self.stdout, "{RESET}{DEFAULT_CURSOR}{SHOW_CURSOR}\r\n")?; - self.stdout.flush()?; - if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &self.original) } != 0 { - return Err(io::Error::last_os_error()).context("restore terminal"); - } - self.left = true; - Ok(()) - } - - fn size(&self) -> (usize, usize) { - let mut ws = unsafe { std::mem::zeroed::() }; - if unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) } == 0 - && ws.ws_col > 0 - && ws.ws_row > 0 - { - (ws.ws_col as usize, ws.ws_row as usize) - } else { - (88, 24) - } - } - - fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> { - self.stdout.write_all(bytes) - } - - fn flush(&mut self) -> io::Result<()> { - self.stdout.flush() - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - let _ = self.leave(); - } -} - -fn input_ready(timeout: Duration) -> Result { - let mut fds = libc::pollfd { - fd: libc::STDIN_FILENO, - events: libc::POLLIN, - revents: 0, - }; - let ms = timeout.as_millis().min(i32::MAX as u128) as i32; - let rc = unsafe { libc::poll(&mut fds, 1, ms) }; - if rc < 0 { - return Err(io::Error::last_os_error()).context("poll stdin"); - } - Ok(rc > 0 && (fds.revents & libc::POLLIN) != 0) -} - -fn parse_one_key(bytes: &[u8]) -> Option<(Key, usize)> { - if bytes.is_empty() { - return None; - } - match bytes[0] { - 3 => return Some((Key::CtrlC, 1)), - 4 => return Some((Key::CtrlD, 1)), - 9 => return Some((Key::Tab, 1)), - 10 => return Some((Key::CtrlJ, 1)), - 13 => return Some((Key::Enter, 1)), - 27 => {} - 127 | 8 => return Some((Key::Backspace, 1)), - _ => return None, - } - - if bytes.starts_with(b"\x1b[200~") { - if let Some(end) = find_subslice(bytes, b"\x1b[201~") { - let body = &bytes[6..end]; - return Some(( - Key::Paste(String::from_utf8_lossy(body).to_string()), - end + 6, - )); - } - return None; - } - for (seq, key) in [ - (b"\x1b[A".as_slice(), Key::Up), - (b"\x1b[B".as_slice(), Key::Down), - (b"\x1b[C".as_slice(), Key::Right), - (b"\x1b[D".as_slice(), Key::Left), - (b"\x1b[H".as_slice(), Key::Home), - (b"\x1b[1~".as_slice(), Key::Home), - (b"\x1b[F".as_slice(), Key::End), - (b"\x1b[4~".as_slice(), Key::End), - (b"\x1b[3~".as_slice(), Key::Delete), - (b"\x1b[5~".as_slice(), Key::PageUp), - (b"\x1b[6~".as_slice(), Key::PageDown), - (b"\x1b\r".as_slice(), Key::AltEnter), - (b"\x1b\n".as_slice(), Key::AltEnter), - (b"\x1b[13;2u".as_slice(), Key::ShiftEnter), - ] { - if bytes.starts_with(seq) { - return Some((key, seq.len())); - } - } - if bytes[0] == 27 { - if bytes.len() == 1 { - return Some((Key::Esc, 1)); - } - if !bytes[1..].starts_with(b"[") { - return Some((Key::Esc, 1)); - } - if !bytes[1].is_ascii() { - return Some((Key::Esc, 1)); - } - } - None -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -fn push_transcript_block(out: &mut Vec, lines: Vec) { - let mut wrote = false; - for line in lines { - out.push(TranscriptLine { - text: line, - block_start: !wrote, - }); - wrote = true; - } - if !wrote { - out.push(TranscriptLine { - text: String::new(), - block_start: true, - }); - } -} - -fn snap_scroll_to_block_start(lines: &[TranscriptLine], mut index: usize) -> usize { - if lines.is_empty() { - return 0; - } - index = index.min(lines.len() - 1); - while index > 0 && !lines[index].block_start { - index -= 1; - } - index -} - -fn snap_scroll_to_next_block_start(lines: &[TranscriptLine], mut index: usize) -> usize { - if lines.is_empty() { - return 0; - } - index = index.min(lines.len() - 1); - if lines[index].block_start { - return index; - } - while index + 1 < lines.len() && !lines[index].block_start { - index += 1; - } - if lines[index].block_start { - index - } else { - snap_scroll_to_block_start(lines, index) - } -} - -fn wrap_text(text: &str, width: usize) -> Vec { - wrap_ansi(text, width.max(1), "") -} - -fn wrap_text_with_indent(text: &str, width: usize) -> Vec { - let indent = leading_indent(text); - let continuation = if indent.is_empty() { - String::new() - } else { - format!("{indent} ") - }; - wrap_ansi(text, width.max(1), &continuation) -} - -fn wrap_ansi(text: &str, width: usize, continuation: &str) -> Vec { - let plain_width = width.max(1); - let mut out = Vec::new(); - for raw in text.split('\n') { - let mut current = String::new(); - let mut col = 0usize; - let mut chars = raw.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' { - current.push(ch); - for next in chars.by_ref() { - current.push(next); - if next.is_ascii_alphabetic() { - break; - } - } - continue; - } - let w = ch.width().unwrap_or(0); - if col > 0 && col + w > plain_width { - out.push(current); - current = continuation.to_string(); - col = display_width(continuation); - } - current.push(ch); - col += w; - } - out.push(current); - } - out -} - -fn render_tool_call_lines(name: &str, summary: &str, width: usize) -> Vec { - let prefix = format!("{GREEN}●{RESET} {BOLD}{name}{RESET}"); - let prefix_width = display_width(&prefix); - let mut lines = Vec::new(); - if summary.trim().is_empty() { - lines.push(prefix); - return lines; - } - let gap = " "; - let available = width.saturating_sub(prefix_width + gap.len()).max(24); - let summary_lines = wrap_summary(summary, available); - for (idx, line) in summary_lines.into_iter().enumerate() { - if idx == 0 { - lines.push(format!("{prefix}{gap}{DIM}{line}{RESET}")); - } else { - lines.push(format!(" {DIM}{line}{RESET}")); - } - } - lines -} - -fn command_rule(width: usize) -> String { - let width = width.clamp(28, 160); - format!( - "{DIM}{} commands{RESET}", - "─".repeat(width.saturating_sub(9)) - ) -} - -fn wrap_summary(summary: &str, width: usize) -> Vec { - let clean = summary.split_whitespace().collect::>().join(" "); - let mut lines = Vec::new(); - let mut current = String::new(); - for word in clean.split(' ') { - let word_width = display_width(word); - if word_width > width { - if !current.is_empty() { - lines.push(std::mem::take(&mut current)); - } - lines.extend(split_visible(word, width)); - continue; - } - let current_width = display_width(¤t); - if current.is_empty() { - current.push_str(word); - } else if current_width + 1 + word_width <= width { - current.push(' '); - current.push_str(word); - } else { - lines.push(std::mem::take(&mut current)); - current.push_str(word); - } - } - if !current.is_empty() { - lines.push(current); - } - if lines.is_empty() { - lines.push(String::new()); - } - lines -} - -fn split_visible(text: &str, width: usize) -> Vec { - let width = width.max(1); - let mut lines = Vec::new(); - let mut current = String::new(); - let mut col = 0usize; - for ch in text.chars() { - let w = ch.width().unwrap_or(0); - if col > 0 && col + w > width { - lines.push(std::mem::take(&mut current)); - col = 0; - } - current.push(ch); - col += w; - } - if !current.is_empty() { - lines.push(current); - } - lines -} - -fn compact_artifact_display_line(line: &str) -> String { - let trimmed = line.trim_start(); - let Some(path) = trimmed.strip_prefix("artifact: ") else { - return line.to_string(); - }; - let leading = &line[..line.len() - trimmed.len()]; - format!("{leading}artifact: {}", short_display_path(path)) -} - -fn short_display_path(path: &str) -> String { - let path = path.trim(); - if path.is_empty() { - return String::new(); - } - let path = PathBuf::from(path); - let base = path - .file_name() - .and_then(|v| v.to_str()) - .unwrap_or_default(); - let dir = path - .parent() - .and_then(Path::file_name) - .and_then(|v| v.to_str()) - .unwrap_or_default(); - if dir.is_empty() { - base.to_string() - } else { - format!("{dir}/{base}") - } -} - -fn leading_indent(text: &str) -> String { - strip_ansi(text) - .chars() - .take_while(|ch| *ch == ' ' || *ch == '\t') - .collect() -} - -fn fit_line(line: &str, width: usize) -> String { - let mut out = String::new(); - let mut col = 0usize; - let mut saw_ansi = false; - let mut chars = line.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' { - saw_ansi = true; - out.push(ch); - for next in chars.by_ref() { - out.push(next); - if next.is_ascii_alphabetic() { - break; - } - } - continue; - } - let w = ch.width().unwrap_or(0); - if col + w > width { - break; - } - out.push(ch); - col += w; - } - if saw_ansi && !out.ends_with(RESET) { - out.push_str(RESET); - } - out -} - -fn render_prompt_lines( - text: String, - raw_input: &str, - cursor: usize, - width: usize, -) -> (Vec, Option<(usize, usize)>) { - let mut lines = wrap_text(&text, width); - if lines.is_empty() { - lines.push(String::new()); - } - let cursor = input_cursor_position(raw_input, cursor, width); - for (idx, line) in lines.iter_mut().enumerate() { - if idx == 0 { - *line = format!("{CYAN}›{RESET} {line}"); - } else { - *line = format!(" {line}"); - } - } - (lines, Some(cursor)) -} - -fn input_cursor_position(input: &str, cursor: usize, width: usize) -> (usize, usize) { - let mut row = 0usize; - let mut col = 0usize; - let width = width.max(1); - for ch in input[..cursor.min(input.len())].chars() { - if ch == '\n' { - row += 1; - col = 0; - continue; - } - let w = ch.width().unwrap_or(0); - if col > 0 && col + w > width { - row += 1; - col = 0; - } - col += w; - } - let prefix_width = if row == 0 { 2 } else { 2 }; - (row, prefix_width + col) -} - -fn display_width(text: &str) -> usize { - strip_ansi(text).width() -} - -fn join_status_line(left: &str, right: &str, width: usize) -> String { - let left_width = display_width(left); - let right_width = display_width(right); - if left_width + right_width + 1 <= width { - return format!( - "{}{}{}", - left, - " ".repeat(width - left_width - right_width), - right - ); - } - if right_width + 2 >= width { - return fit_line(right, width); - } - let keep = width.saturating_sub(right_width + 3); - let mut trimmed = truncate_visible(left, keep); - trimmed.push('…'); - format!( - "{}{}{}", - trimmed, - " ".repeat(width.saturating_sub(display_width(&trimmed) + right_width)), - right - ) -} - -fn truncate_visible(text: &str, width: usize) -> String { - let mut out = String::new(); - let mut col = 0usize; - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' { - out.push(ch); - for next in chars.by_ref() { - out.push(next); - if next.is_ascii_alphabetic() { - break; - } - } - continue; - } - let w = ch.width().unwrap_or(0); - if col + w > width { - break; - } - out.push(ch); - col += w; - } - out -} - -fn strip_ansi(text: &str) -> String { - let mut out = String::new(); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' && chars.peek() == Some(&'[') { - let _ = chars.next(); - for next in chars.by_ref() { - if next.is_ascii_alphabetic() { - break; - } - } - } else { - out.push(ch); - } - } - out -} - -fn format_elapsed(d: Duration) -> String { - let secs = d.as_secs(); - format!("{}:{:02}", secs / 60, secs % 60) -} - -fn short_int(n: u64) -> String { - if n < 1000 { - n.to_string() - } else if n < 100_000 { - format!("{:.1}k", n as f64 / 1000.0) - } else { - format!("{}k", n / 1000) - } -} - -fn add_usage(total: &mut Usage, next: Usage) { - total.prompt_tokens += next.prompt_tokens; - total.completion_tokens += next.completion_tokens; - total.total_tokens += next.total_tokens; -} - -fn format_usage(usage: Usage) -> String { - let total = if usage.total_tokens > 0 { - usage.total_tokens - } else { - usage.prompt_tokens + usage.completion_tokens - }; - format!( - "tok {} · in {} / out {}", - short_int(total), - short_int(usage.prompt_tokens), - short_int(usage.completion_tokens) - ) -} - -fn print_osc52(text: &str) -> Result<()> { - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - eprint!("\x1b]52;c;{}\x07", encoded); - io::stderr().flush()?; - Ok(()) -} diff --git a/rust/crates/openmelon-tui/src/config.rs b/rust/crates/openmelon-tui/src/config.rs deleted file mode 100644 index fd14a71..0000000 --- a/rust/crates/openmelon-tui/src/config.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::collections::BTreeMap; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; - -use crate::project::{Project, ProviderConfig}; - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct UserConfig { - #[serde(default)] - pub current_project: String, - #[serde(default)] - pub defaults: UserDefaults, - #[serde(default)] - pub providers: BTreeMap, - #[serde(default)] - pub trusted_dirs: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct UserDefaults { - #[serde(default)] - pub llm_provider: String, - #[serde(default)] - pub llm_model: String, - #[serde(default)] - pub image_provider: String, - #[serde(default)] - pub image_model: String, - #[serde(default)] - pub vision_model: String, - #[serde(default)] - pub locale: String, - #[serde(default)] - pub reasoning_effort: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct Credentials { - #[serde(default)] - pub api_keys: BTreeMap, -} - -#[derive(Debug, Clone, Default)] -pub struct ProviderResolution { - pub api_key: String, - pub base_url: String, -} - -pub fn openmelon_home() -> Result { - if let Ok(home) = env::var("OPENMELON_HOME") { - return Ok(PathBuf::from(home)); - } - let home = env::var("HOME").context("HOME is not set")?; - Ok(PathBuf::from(home).join(".openmelon")) -} - -pub fn load_user_config() -> Result { - let path = openmelon_home()?.join("config.json"); - if !path.exists() { - return Ok(UserConfig::default()); - } - let body = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - serde_json::from_str(&body).with_context(|| format!("parse {}", path.display())) -} - -pub fn load_credentials(path: &Path) -> Result { - if !path.exists() { - return Ok(Credentials::default()); - } - let body = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - serde_json::from_str(&body).with_context(|| format!("parse {}", path.display())) -} - -pub fn resolve_provider( - workdir: &Path, - project: &Project, - user: &UserConfig, - provider: &str, -) -> Result { - let provider = provider.trim().to_string(); - let mut out = ProviderResolution::default(); - - if let Some(cfg) = project.providers.get(&provider) { - if !cfg.api_key.trim().is_empty() { - out.api_key = cfg.api_key.clone(); - } - if !cfg.base_url.trim().is_empty() { - out.base_url = cfg.base_url.clone(); - } - } - if let Some(cfg) = user.providers.get(&provider) { - if out.api_key.is_empty() && !cfg.api_key.trim().is_empty() { - out.api_key = cfg.api_key.clone(); - } - if out.base_url.is_empty() && !cfg.base_url.trim().is_empty() { - out.base_url = cfg.base_url.clone(); - } - } - if out.api_key.is_empty() { - let project_creds = load_credentials(&workdir.join(".openmelon").join("credentials.json"))?; - if let Some(key) = project_creds.api_keys.get(&provider) { - out.api_key = key.clone(); - } - } - if out.api_key.is_empty() { - let global_creds = load_credentials(&openmelon_home()?.join("credentials.json"))?; - if let Some(key) = global_creds.api_keys.get(&provider) { - out.api_key = key.clone(); - } - } - if out.api_key.is_empty() { - if let Ok(key) = env::var(api_key_env(&provider)) { - out.api_key = key; - } - } - if out.base_url.is_empty() { - if let Ok(base_url) = env::var(base_url_env(&provider)) { - out.base_url = base_url; - } - } - - Ok(out) -} - -pub fn api_key_env(provider: &str) -> &'static str { - match provider { - "anthropic" => "ANTHROPIC_API_KEY", - "openrouter" => "OPENROUTER_API_KEY", - _ => "OPENAI_API_KEY", - } -} - -pub fn base_url_env(provider: &str) -> &'static str { - match provider { - "anthropic" => "ANTHROPIC_BASE_URL", - "openrouter" => "OPENROUTER_BASE_URL", - _ => "OPENAI_BASE_URL", - } -} - -pub fn default_provider() -> String { - if env::var("OPENAI_API_KEY").is_ok() { - return "openai".to_string(); - } - if env::var("OPENROUTER_API_KEY").is_ok() { - return "openrouter".to_string(); - } - if env::var("ANTHROPIC_API_KEY").is_ok() { - return "anthropic".to_string(); - } - "openai".to_string() -} diff --git a/rust/crates/openmelon-tui/src/image.rs b/rust/crates/openmelon-tui/src/image.rs deleted file mode 100644 index e81091d..0000000 --- a/rust/crates/openmelon-tui/src/image.rs +++ /dev/null @@ -1,218 +0,0 @@ -use std::fs; -use std::time::Duration; - -use anyhow::{bail, Context, Result}; -use base64::Engine; -use reqwest::blocking::Client; -use serde::Deserialize; - -#[derive(Debug, Clone)] -pub struct ImageGenerator { - provider: String, - api_key: String, - base_url: String, - model: String, - client: Client, -} - -pub struct ImageResult { - pub data: Vec, - pub content_type: String, -} - -impl ImageResult { - pub fn extension(&self) -> &'static str { - match self.content_type.as_str() { - "image/jpeg" => ".jpg", - "image/webp" => ".webp", - "image/gif" => ".gif", - _ => ".png", - } - } -} - -impl ImageGenerator { - pub fn new(provider: &str, api_key: String, base_url: String, model: String) -> Result { - if model.trim().is_empty() { - bail!("image model is not configured"); - } - if api_key.trim().is_empty() { - bail!("no API key for image provider {provider}"); - } - let base_url = match (provider, base_url.trim()) { - (_, url) if !url.is_empty() => url.trim_end_matches('/').to_string(), - ("openrouter", _) => "https://openrouter.ai/api".to_string(), - _ => "https://api.openai.com".to_string(), - }; - Ok(Self { - provider: provider.to_string(), - api_key, - base_url, - model, - client: Client::builder() - .timeout(Duration::from_secs(300)) - .build()?, - }) - } - - pub fn generate( - &self, - prompt: &str, - size: &str, - reference_images: &[String], - ) -> Result { - match self.provider.as_str() { - "openrouter" => self.generate_openrouter(prompt, reference_images), - "openai" | "" => self.generate_openai(prompt, size), - other => bail!("unsupported image provider {other}"), - } - } - - fn generate_openai(&self, prompt: &str, size: &str) -> Result { - let mut body = serde_json::json!({ - "model": self.model, - "prompt": prompt, - "n": 1, - }); - if !size.trim().is_empty() { - body["size"] = serde_json::json!(size.trim()); - } - let resp = self - .client - .post(format!("{}/v1/images/generations", self.base_url)) - .bearer_auth(&self.api_key) - .header("content-type", "application/json") - .json(&body) - .send() - .context("send image request")?; - let status = resp.status(); - let text = resp.text()?; - if !status.is_success() { - bail!("imagegen[openai]: HTTP {}: {}", status.as_u16(), text); - } - let parsed: OpenAIImageResponse = serde_json::from_str(&text)?; - let b64 = parsed - .data - .first() - .map(|d| d.b64_json.as_str()) - .filter(|s| !s.is_empty()) - .context("image response did not include b64_json")?; - Ok(ImageResult { - data: base64::engine::general_purpose::STANDARD.decode(b64)?, - content_type: "image/png".to_string(), - }) - } - - fn generate_openrouter( - &self, - prompt: &str, - reference_images: &[String], - ) -> Result { - let content = if reference_images.is_empty() { - serde_json::json!(prompt) - } else { - let mut parts = Vec::new(); - for path in reference_images { - let bytes = - fs::read(path).with_context(|| format!("read reference image {path}"))?; - parts.push(serde_json::json!({ - "type": "image_url", - "image_url": { - "url": format!("data:{};base64,{}", sniff_content_type(&bytes), base64::engine::general_purpose::STANDARD.encode(bytes)) - } - })); - } - parts.push(serde_json::json!({ "type": "text", "text": prompt })); - serde_json::Value::Array(parts) - }; - let body = serde_json::json!({ - "model": self.model, - "modalities": ["image", "text"], - "messages": [{"role": "user", "content": content}], - }); - let resp = self - .client - .post(format!("{}/v1/chat/completions", self.base_url)) - .bearer_auth(&self.api_key) - .header("content-type", "application/json") - .header( - "HTTP-Referer", - "https://github.com/eight-acres-lab/openmelon", - ) - .header("X-Title", "openmelon") - .json(&body) - .send() - .context("send openrouter image request")?; - let status = resp.status(); - let text = resp.text()?; - if !status.is_success() { - bail!("imagegen[openrouter]: HTTP {}: {}", status.as_u16(), text); - } - let parsed: OpenRouterImageResponse = serde_json::from_str(&text)?; - let url = parsed - .choices - .first() - .and_then(|c| c.message.images.first()) - .map(|img| img.image_url.url.as_str()) - .context("openrouter response did not include an image")?; - decode_data_url(url) - } -} - -fn sniff_content_type(bytes: &[u8]) -> &'static str { - if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { - "image/png" - } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { - "image/jpeg" - } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { - "image/webp" - } else { - "image/png" - } -} - -fn decode_data_url(value: &str) -> Result { - let rest = value.strip_prefix("data:").context("not a data URL")?; - let (header, payload) = rest.split_once(',').context("data URL missing comma")?; - let content_type = header.split(';').next().unwrap_or("image/png").to_string(); - Ok(ImageResult { - data: base64::engine::general_purpose::STANDARD.decode(payload)?, - content_type, - }) -} - -#[derive(Debug, Deserialize)] -struct OpenAIImageResponse { - data: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpenAIImageData { - b64_json: String, -} - -#[derive(Debug, Deserialize)] -struct OpenRouterImageResponse { - choices: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpenRouterChoice { - message: OpenRouterMessage, -} - -#[derive(Debug, Deserialize)] -struct OpenRouterMessage { - #[serde(default)] - images: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpenRouterImage { - image_url: OpenRouterImageURL, -} - -#[derive(Debug, Deserialize)] -struct OpenRouterImageURL { - url: String, -} diff --git a/rust/crates/openmelon-tui/src/llm.rs b/rust/crates/openmelon-tui/src/llm.rs deleted file mode 100644 index 1c36a87..0000000 --- a/rust/crates/openmelon-tui/src/llm.rs +++ /dev/null @@ -1,454 +0,0 @@ -use std::time::Duration; - -use anyhow::{bail, Context, Result}; -use reqwest::blocking::Client; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; -use std::io::{BufRead, BufReader}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum Role { - System, - User, - Assistant, - Tool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub role: Role, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub content: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tool_calls: Vec, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub tool_call_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Tool { - pub name: String, - pub description: String, - pub parameters: Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub name: String, - pub arguments: Value, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FinishReason { - Stop, - ToolCalls, - Length, - Other, -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct Usage { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, -} - -#[derive(Debug, Clone)] -pub struct ChatRequest { - pub messages: Vec, - pub tools: Vec, - pub reasoning_effort: String, -} - -#[derive(Debug, Clone)] -pub struct ChatResponse { - pub message: Message, - pub finish_reason: FinishReason, - pub usage: Usage, -} - -#[derive(Debug, Clone)] -pub struct OpenAIClient { - provider: String, - api_key: String, - base_url: String, - model: String, - client: Client, -} - -impl OpenAIClient { - pub fn new(provider: &str, api_key: String, base_url: String, model: String) -> Result { - if provider == "anthropic" { - bail!("anthropic is not implemented in the Rust runtime yet; use openai or openrouter"); - } - if api_key.trim().is_empty() { - bail!("no API key for {provider}; run openmelon setup or configure credentials"); - } - if model.trim().is_empty() { - bail!("no LLM model configured"); - } - let base_url = match (provider, base_url.trim()) { - (_, url) if !url.is_empty() => url.trim_end_matches('/').to_string(), - ("openrouter", _) => "https://openrouter.ai/api".to_string(), - _ => "https://api.openai.com".to_string(), - }; - Ok(Self { - provider: provider.to_string(), - api_key, - base_url, - model, - client: Client::builder() - .timeout(Duration::from_secs(180)) - .build()?, - }) - } - - pub fn provider(&self) -> &str { - &self.provider - } - - pub fn model(&self) -> &str { - &self.model - } - - pub fn stream_chat( - &self, - request: ChatRequest, - mut on_text: impl FnMut(&str), - ) -> Result { - if request.messages.is_empty() { - bail!("stream chat requires at least one message"); - } - - let mut body = self.chat_body(&request); - body["stream"] = Value::Bool(true); - body["stream_options"] = serde_json::json!({ "include_usage": true }); - - let url = format!("{}/v1/chat/completions", self.base_url); - let mut req = self - .client - .post(url) - .bearer_auth(&self.api_key) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("user-agent", user_agent()); - if self.provider == "openrouter" { - req = req - .header( - "HTTP-Referer", - "https://github.com/eight-acres-lab/openmelon", - ) - .header("X-Title", "openmelon"); - } - - let resp = req.json(&body).send().context("send stream chat request")?; - let status = resp.status(); - if !status.is_success() { - let text = resp.text().unwrap_or_default(); - bail!("llm[{}]: HTTP {}: {}", self.provider, status.as_u16(), text); - } - - let mut text = String::new(); - let mut tool_calls: BTreeMap = BTreeMap::new(); - let mut finish_reason = FinishReason::Other; - let mut usage = Usage::default(); - - read_sse(resp, |event| { - if event.data == "[DONE]" { - return Ok(false); - } - let chunk: StreamChunkWire = serde_json::from_str(&event.data) - .with_context(|| format!("parse stream chunk: {}", event.data))?; - if let Some(u) = chunk.usage { - usage = u.into(); - } - let Some(choice) = chunk.choices.into_iter().next() else { - return Ok(true); - }; - if let Some(delta) = choice.delta.content { - if !delta.is_empty() { - text.push_str(&delta); - on_text(&delta); - } - } - for call in choice.delta.tool_calls.unwrap_or_default() { - let entry = tool_calls.entry(call.index).or_default(); - if let Some(id) = call.id { - if !id.is_empty() { - entry.id = id; - } - } - if let Some(function) = call.function { - if let Some(name) = function.name { - if !name.is_empty() { - entry.name = name; - } - } - if let Some(arguments) = function.arguments { - entry.arguments.push_str(&arguments); - } - } - } - if let Some(reason) = choice.finish_reason { - finish_reason = map_finish_reason(&reason); - } - Ok(true) - })?; - - let calls = tool_calls - .into_values() - .map(|partial| ToolCall { - id: partial.id, - name: partial.name, - arguments: if partial.arguments.trim().is_empty() { - serde_json::json!({}) - } else { - serde_json::from_str(&partial.arguments) - .unwrap_or_else(|_| serde_json::json!({ "raw": partial.arguments })) - }, - }) - .collect(); - - Ok(ChatResponse { - message: Message { - role: Role::Assistant, - content: text, - tool_call_id: String::new(), - tool_calls: calls, - }, - finish_reason, - usage, - }) - } - - fn chat_body(&self, request: &ChatRequest) -> Value { - let wire_messages = request - .messages - .iter() - .map(to_wire_message) - .collect::>(); - let wire_tools = request - .tools - .iter() - .map(|tool| { - serde_json::json!({ - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - } - }) - }) - .collect::>(); - - let mut body = serde_json::json!({ - "model": self.model, - "messages": wire_messages, - "temperature": 0.7, - }); - if !wire_tools.is_empty() { - body["tools"] = Value::Array(wire_tools); - } - if let Some(effort) = normalize_reasoning_effort(&request.reasoning_effort) { - body["reasoning_effort"] = Value::String(effort.to_string()); - } - body - } -} - -fn user_agent() -> String { - format!( - "openmelon-tui/{} ({}; {})", - env!("CARGO_PKG_VERSION"), - std::env::consts::OS, - std::env::consts::ARCH - ) -} - -fn normalize_reasoning_effort(value: &str) -> Option<&str> { - match value.trim().to_ascii_lowercase().as_str() { - "none" => Some("none"), - "minimal" => Some("minimal"), - "low" => Some("low"), - "medium" => Some("medium"), - "high" => Some("high"), - "xhigh" => Some("xhigh"), - _ => None, - } -} - -fn to_wire_message(message: &Message) -> Value { - let role = match message.role { - Role::System => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => "tool", - }; - let mut out = serde_json::json!({ - "role": role, - }); - if !message.content.is_empty() { - out["content"] = Value::String(message.content.clone()); - } - if !message.tool_call_id.is_empty() { - out["tool_call_id"] = Value::String(message.tool_call_id.clone()); - } - if !message.tool_calls.is_empty() { - out["tool_calls"] = Value::Array( - message - .tool_calls - .iter() - .map(|call| { - serde_json::json!({ - "id": call.id, - "type": "function", - "function": { - "name": call.name, - "arguments": serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".to_string()), - } - }) - }) - .collect(), - ); - } - out -} - -fn map_finish_reason(value: &str) -> FinishReason { - match value { - "stop" => FinishReason::Stop, - "tool_calls" => FinishReason::ToolCalls, - "length" => FinishReason::Length, - _ => FinishReason::Other, - } -} - -#[derive(Debug, Deserialize, Default)] -struct UsageWire { - #[serde(default)] - prompt_tokens: u64, - #[serde(default)] - completion_tokens: u64, - #[serde(default)] - total_tokens: u64, -} - -#[derive(Debug, Default)] -struct PartialToolCall { - id: String, - name: String, - arguments: String, -} - -struct SseEvent { - data: String, -} - -fn read_sse( - response: reqwest::blocking::Response, - mut on_event: impl FnMut(SseEvent) -> Result, -) -> Result<()> { - let mut reader = BufReader::new(response); - let mut line = String::new(); - let mut data = String::new(); - loop { - line.clear(); - let n = reader.read_line(&mut line)?; - if n == 0 { - if !data.is_empty() { - let _ = on_event(SseEvent { data })?; - } - return Ok(()); - } - let trimmed = line.trim_end_matches(['\r', '\n']); - if trimmed.is_empty() { - if !data.is_empty() { - let keep_going = on_event(SseEvent { data: data.clone() })?; - data.clear(); - if !keep_going { - return Ok(()); - } - } - continue; - } - if let Some(rest) = trimmed.strip_prefix("data:") { - if !data.is_empty() { - data.push('\n'); - } - data.push_str(rest.trim()); - } - } -} - -#[derive(Debug, Deserialize)] -struct StreamChunkWire { - #[serde(default)] - choices: Vec, - usage: Option, -} - -#[derive(Debug, Deserialize)] -struct StreamChoiceWire { - #[serde(default)] - delta: StreamDeltaWire, - finish_reason: Option, -} - -#[derive(Debug, Deserialize, Default)] -struct StreamDeltaWire { - content: Option, - tool_calls: Option>, -} - -#[derive(Debug, Deserialize)] -struct StreamToolCallWire { - index: usize, - id: Option, - function: Option, -} - -#[derive(Debug, Deserialize)] -struct StreamToolCallFunctionWire { - name: Option, - arguments: Option, -} - -impl From for Usage { - fn from(value: UsageWire) -> Self { - Self { - prompt_tokens: value.prompt_tokens, - completion_tokens: value.completion_tokens, - total_tokens: value.total_tokens, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assistant_tool_call_round_trips_to_openai_wire() { - let msg = Message { - role: Role::Assistant, - content: String::new(), - tool_call_id: String::new(), - tool_calls: vec![ToolCall { - id: "call_1".to_string(), - name: "finish".to_string(), - arguments: serde_json::json!({"summary":"done"}), - }], - }; - - let wire = to_wire_message(&msg); - assert_eq!(wire["tool_calls"][0]["function"]["name"], "finish"); - assert_eq!( - wire["tool_calls"][0]["function"]["arguments"], - "{\"summary\":\"done\"}" - ); - } -} diff --git a/rust/crates/openmelon-tui/src/main.rs b/rust/crates/openmelon-tui/src/main.rs deleted file mode 100644 index 7729840..0000000 --- a/rust/crates/openmelon-tui/src/main.rs +++ /dev/null @@ -1,99 +0,0 @@ -mod app; -mod config; -mod image; -mod llm; -mod project; -mod render; -mod runtime; -mod session; -mod terminal; -mod tools; - -use std::path::PathBuf; - -use anyhow::Result; -use clap::{Parser, Subcommand}; - -#[derive(Debug, Parser)] -#[command(name = "openmelon-tui")] -#[command(about = "Rust TUI for OpenMelon")] -struct Cli { - #[arg(long, default_value = ".")] - workdir: PathBuf, - - #[arg(short = 'p', long)] - prompt: Option, - - #[arg(long)] - resume: Option, - - #[arg(long)] - llm: Option, - - #[arg(long = "llm-model")] - llm_model: Option, - - #[arg(long = "llm-base-url")] - llm_base_url: Option, - - #[arg(long = "reasoning-effort")] - reasoning_effort: Option, - - #[arg(long = "image-provider")] - image_provider: Option, - - #[arg(long = "image-model")] - image_model: Option, - - #[arg(long = "image-base-url")] - image_base_url: Option, - - #[arg(long = "max-steps", default_value_t = 24)] - max_steps: usize, - - #[command(subcommand)] - command: Option, -} - -#[derive(Debug, Subcommand)] -enum Command { - Repl, - EventTui, - Demo, - Run { prompt: String }, - Resume { session_id: String }, -} - -fn main() -> Result<()> { - let cli = Cli::parse(); - let command = match cli.command { - Some(command) => command, - None if cli.prompt.is_some() => Command::Run { - prompt: cli.prompt.clone().unwrap_or_default(), - }, - None if cli.resume.is_some() => Command::Resume { - session_id: cli.resume.clone().unwrap_or_default(), - }, - None => Command::Repl, - }; - - let options = app::AppOptions { - workdir: cli.workdir, - provider: cli.llm, - model: cli.llm_model, - base_url: cli.llm_base_url, - reasoning_effort: cli.reasoning_effort, - image_provider: cli.image_provider, - image_model: cli.image_model, - image_base_url: cli.image_base_url, - max_steps: cli.max_steps, - }; - - match command { - Command::Repl => app::App::new(options, None)?.run(), - Command::EventTui => app::App::new(options, None)?.run_event_tui(), - Command::Demo => app::App::new_demo(options.workdir)?.run_demo(), - Command::Run { prompt } => app::App::new(options, None)?.run_one_shot(prompt), - Command::Resume { session_id } => app::App::new(options, Some(session_id))?.run(), - } -} diff --git a/rust/crates/openmelon-tui/src/project.rs b/rust/crates/openmelon-tui/src/project.rs deleted file mode 100644 index dfa9b8d..0000000 --- a/rust/crates/openmelon-tui/src/project.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Context, Result}; -use serde::{Deserialize, Serialize}; - -pub const STATE_DIR: &str = ".openmelon"; -pub const PROJECT_FILE: &str = "project.json"; -pub const OUTPUTS_DIR: &str = "outputs"; - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct Project { - pub id: String, - pub name: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub persona: String, - #[serde(default)] - pub constraints: Vec, - #[serde(default)] - pub defaults: Defaults, - #[serde(default)] - pub providers: std::collections::BTreeMap, - #[serde(default)] - pub settings: Settings, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct Defaults { - #[serde(default)] - pub llm_provider: String, - #[serde(default)] - pub llm_model: String, - #[serde(default)] - pub image_provider: String, - #[serde(default)] - pub image_model: String, - #[serde(default)] - pub vision_model: String, - #[serde(default)] - pub locale: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ProviderConfig { - #[serde(default)] - pub api_key: String, - #[serde(default)] - pub base_url: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct Settings { - #[serde(default)] - pub bash_permission_mode: String, - #[serde(default)] - pub reasoning_effort: String, -} - -#[derive(Debug, Clone)] -pub struct Workspace { - pub root: PathBuf, - pub project: Project, -} - -impl Workspace { - pub fn discover(start: impl AsRef) -> Result { - let root = discover_root(start)?; - let project = load_project(&root)?; - ensure_project_dirs(&root)?; - Ok(Self { root, project }) - } - - pub fn state_dir(&self) -> PathBuf { - self.root.join(STATE_DIR) - } - - pub fn outputs_dir(&self) -> PathBuf { - self.root.join(OUTPUTS_DIR) - } - - pub fn session_outputs_dir(&self, session_id: &str) -> PathBuf { - self.outputs_dir().join("sessions").join(session_id) - } - - pub fn artifact_outputs_dir(&self, slug: &str, timestamp: &str) -> PathBuf { - self.outputs_dir() - .join("artifacts") - .join(slug) - .join(timestamp) - } -} - -pub fn discover_root(start: impl AsRef) -> Result { - let mut cur = start - .as_ref() - .canonicalize() - .with_context(|| format!("resolve {}", start.as_ref().display()))?; - - loop { - if cur.join(STATE_DIR).join(PROJECT_FILE).is_file() { - return Ok(cur); - } - if !cur.pop() { - bail!( - "not an openmelon project: no {}/{} found from {} upward", - STATE_DIR, - PROJECT_FILE, - start.as_ref().display() - ); - } - } -} - -pub fn load_project(root: &Path) -> Result { - let path = root.join(STATE_DIR).join(PROJECT_FILE); - let body = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - serde_json::from_str(&body).with_context(|| format!("parse {}", path.display())) -} - -pub fn update_project_json(root: &Path, update: impl FnOnce(&mut serde_json::Value)) -> Result<()> { - let path = root.join(STATE_DIR).join(PROJECT_FILE); - let body = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let mut value: serde_json::Value = - serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?; - update(&mut value); - fs::write(&path, serde_json::to_string_pretty(&value)? + "\n") - .with_context(|| format!("write {}", path.display()))?; - Ok(()) -} - -pub fn set_project_default(root: &Path, key: &str, value: &str) -> Result<()> { - update_project_json(root, |project| { - ensure_object(project, "defaults"); - project["defaults"][key] = serde_json::Value::String(value.to_string()); - }) -} - -pub fn set_project_setting(root: &Path, key: &str, value: &str) -> Result<()> { - update_project_json(root, |project| { - ensure_object(project, "settings"); - if value.trim().is_empty() { - if let Some(obj) = project["settings"].as_object_mut() { - obj.remove(key); - } - } else { - project["settings"][key] = serde_json::Value::String(value.to_string()); - } - }) -} - -fn ensure_object(value: &mut serde_json::Value, key: &str) { - if !value.get(key).is_some_and(serde_json::Value::is_object) { - value[key] = serde_json::json!({}); - } -} - -pub fn ensure_project_dirs(root: &Path) -> Result<()> { - for sub in [ - "characters", - "references", - "materials", - "sessions", - "spaces", - ] { - fs::create_dir_all(root.join(STATE_DIR).join(sub)) - .with_context(|| format!("create .openmelon/{sub}"))?; - } - fs::create_dir_all(root.join(OUTPUTS_DIR)).context("create outputs dir")?; - Ok(()) -} - -pub fn resolve_output_dir(root: &Path, requested: &str, fallback: &Path) -> Result { - let candidate = if requested.trim().is_empty() { - fallback.to_path_buf() - } else { - let requested = PathBuf::from(requested.trim()); - if requested.is_absolute() { - requested - } else { - root.join(requested) - } - }; - - let root = root.canonicalize()?; - let state = root.join(STATE_DIR); - let abs = if candidate.exists() { - candidate.canonicalize()? - } else { - let parent = candidate.parent().unwrap_or(&root); - let parent_abs = if parent.exists() { - parent.canonicalize()? - } else { - root.clone() - }; - parent_abs.join(candidate.file_name().unwrap_or_default()) - }; - - if !abs.starts_with(&root) { - bail!("output dir {} escapes project workdir", candidate.display()); - } - if abs.starts_with(&state) { - bail!( - "output dir {} is inside .openmelon; choose a visible project directory", - candidate.display() - ); - } - Ok(abs) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_output_rejects_hidden_state() { - let root = std::env::temp_dir().join("openmelon-rust-project"); - let _ = std::fs::create_dir_all(root.join(".openmelon")); - let fallback = root.join("outputs"); - let err = resolve_output_dir(&root, ".openmelon/artifacts", &fallback).unwrap_err(); - assert!(err.to_string().contains(".openmelon")); - } -} diff --git a/rust/crates/openmelon-tui/src/render.rs b/rust/crates/openmelon-tui/src/render.rs deleted file mode 100644 index 4d0f272..0000000 --- a/rust/crates/openmelon-tui/src/render.rs +++ /dev/null @@ -1,935 +0,0 @@ -use std::path::{Path, PathBuf}; - -use serde_json::Value; - -use crate::llm::{Message, Role, ToolCall}; - -const RESET: &str = "\x1b[0m"; -const BOLD: &str = "\x1b[1m"; -const DIM: &str = "\x1b[2m"; -const RED: &str = "\x1b[31m"; -const GREEN: &str = "\x1b[32m"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BlockKind { - Assistant, - Tool, - Error, -} - -#[derive(Debug, Clone)] -pub struct Block { - pub kind: BlockKind, - pub body: String, -} - -pub fn divider(width: usize) -> String { - let len = width.clamp(24, 100); - format!("{}{}{}", DIM, "─".repeat(len), RESET) -} - -pub fn history_rule(label: &str, width: usize) -> String { - let width = width.clamp(28, 160); - let text = format!(" {label} "); - let remaining = width.saturating_sub(text.chars().count()); - if remaining < 4 { - return format!("{DIM}── {label}{RESET}"); - } - let left = remaining / 2; - let right = remaining - left; - format!( - "{DIM}{}{}{}{RESET}", - "─".repeat(left), - text, - "─".repeat(right) - ) -} - -pub fn render_block(block: &Block, width: usize) -> String { - let mut out = String::new(); - - match block.kind { - BlockKind::Assistant => { - out.push_str(&render_markdown(&block.body, width)); - } - BlockKind::Tool => { - out.push_str(&render_prefixed(&block.body, &format!("{GREEN}● {RESET}"))); - } - BlockKind::Error => { - out.push_str(RED); - out.push_str(&render_prefixed(&block.body, "")); - out.push_str(RESET); - } - } - - out -} - -#[derive(Debug, Clone, Copy)] -pub enum TranscriptMode { - Styled, - Plain, -} - -pub fn render_history(messages: &[Message], width: usize, mode: TranscriptMode) -> String { - if messages.is_empty() { - return String::new(); - } - let mut out = String::new(); - out.push('\n'); - push_line( - &mut out, - &history_rule( - &format!("prior conversation ({} messages)", messages.len()), - width, - ), - ); - - let mut tool_names = std::collections::BTreeMap::::new(); - let mut wrote = false; - for message in messages { - if let Some(block) = render_history_message(message, &mut tool_names, width, mode) { - if wrote && !block.starts_with(" └") && !block.starts_with("└") { - out.push('\n'); - } - out.push_str(&block); - if !block.ends_with('\n') { - out.push('\n'); - } - wrote = true; - } - } - - push_line(&mut out, &history_rule("continue below", width)); - out -} - -pub fn render_plain_transcript(messages: &[Message], width: usize) -> String { - render_history(messages, width, TranscriptMode::Plain) -} - -fn render_history_message( - message: &Message, - tool_names: &mut std::collections::BTreeMap, - width: usize, - mode: TranscriptMode, -) -> Option { - match message.role { - Role::System => None, - Role::User => Some(render_user_message(&message.content)), - Role::Assistant => { - let mut out = String::new(); - if !message.content.trim().is_empty() { - out.push_str(&render_markdown_block(&message.content, " ", width, mode)); - out.push('\n'); - } - for call in &message.tool_calls { - if !call.id.is_empty() { - tool_names.insert(call.id.clone(), call.name.clone()); - } - if call.name != "finish" { - out.push_str(&render_tool_call(call, mode)); - out.push('\n'); - } - } - (!out.trim().is_empty()).then_some(out.trim_end().to_string()) - } - Role::Tool => { - let tool_name = tool_names - .get(&message.tool_call_id) - .map(String::as_str) - .unwrap_or(""); - if tool_name == "finish" { - Some(render_finish_result(&message.content, width, mode)) - } else { - Some(render_tool_result(tool_name, &message.content, mode)) - } - } - } -} - -pub fn render_user_message(content: &str) -> String { - let mut out = String::new(); - let mut lines = content.lines(); - if let Some(first) = lines.next() { - push_line(&mut out, &format!("> {first}")); - for line in lines { - push_line(&mut out, &format!(" {line}")); - } - } else { - push_line(&mut out, ">"); - } - out.trim_end().to_string() -} - -pub fn render_tool_call(call: &ToolCall, mode: TranscriptMode) -> String { - if call.name == "finish" { - return String::new(); - } - let name = match mode { - TranscriptMode::Styled => format!("{BOLD}{}{RESET}", call.name), - TranscriptMode::Plain => call.name.clone(), - }; - let dot = match mode { - TranscriptMode::Styled => format!("{GREEN}●{RESET}"), - TranscriptMode::Plain => "●".to_string(), - }; - let summary = tool_call_summary(call); - if summary.is_empty() { - format!("{dot} {name}") - } else { - let summary = match mode { - TranscriptMode::Styled => format!("{DIM}{summary}{RESET}"), - TranscriptMode::Plain => summary, - }; - format!("{dot} {name} {summary}") - } -} - -pub fn render_tool_result(tool_name: &str, content: &str, mode: TranscriptMode) -> String { - let body = if let Some(err) = tool_error_message(content) { - match mode { - TranscriptMode::Styled => format!("{RED}└ error: {err}{RESET}"), - TranscriptMode::Plain => format!("└ error: {err}"), - } - } else { - format!("└ {}", tool_result_summary(tool_name, content)) - }; - format!(" {body}") -} - -pub fn render_finish_result(content: &str, width: usize, mode: TranscriptMode) -> String { - if let Some(err) = tool_error_message(content) { - return match mode { - TranscriptMode::Styled => format!(" {RED}error: {err}{RESET}"), - TranscriptMode::Plain => format!(" error: {err}"), - }; - } - let Some(obj) = json_object_str(content) else { - return render_markdown_block(content, " ", width, mode); - }; - let mut out = String::new(); - let summary = string_field(&obj, "summary"); - if !summary.is_empty() { - out.push_str(&render_markdown_block(&summary, " ", width, mode)); - } - let artifacts = artifact_strings(&obj); - if !artifacts.is_empty() { - if !out.trim().is_empty() { - out.push('\n'); - } - for path in artifacts { - push_line(&mut out, &format!(" artifact: {}", short_path(&path))); - } - } - out.trim_end().to_string() -} - -pub fn render_markdown_block( - markdown: &str, - prefix: &str, - width: usize, - mode: TranscriptMode, -) -> String { - let body = match mode { - TranscriptMode::Styled => render_markdown(markdown, width), - TranscriptMode::Plain => strip_ansi(&render_markdown(markdown, width)), - }; - body.lines() - .map(|line| format!("{prefix}{line}")) - .collect::>() - .join("\n") -} - -pub fn flush_markdown_buffer( - buffer: &mut String, - force: bool, - width: usize, - mode: TranscriptMode, -) -> Option { - if buffer.is_empty() { - return None; - } - if !force && !has_stable_markdown_boundary(buffer) { - return None; - } - let raw = buffer.trim_end_matches('\n').to_string(); - buffer.clear(); - if raw.trim().is_empty() { - return None; - } - Some(render_markdown_block(&raw, " ", width, mode)) -} - -pub fn has_stable_markdown_boundary(raw: &str) -> bool { - raw.trim_end_matches([' ', '\t']).ends_with("\n\n") -} - -pub fn render_markdown(markdown: &str, _width: usize) -> String { - let mut out = String::new(); - let mut in_code = false; - - for raw in markdown.lines() { - let line = raw.trim_end(); - if let Some(rendered) = render_markdown_line(line, &mut in_code) { - out.push_str(&rendered); - out.push('\n'); - } - } - - out.trim_end().to_string() -} - -fn render_markdown_line(line: &str, in_code: &mut bool) -> Option { - if line.trim_start().starts_with("```") { - *in_code = !*in_code; - return None; - } - - if *in_code { - return Some(format!("{DIM}{}{}", render_prefixed(line, " "), RESET)); - } - - if line.is_empty() { - return Some(String::new()); - } - - if let Some(title) = heading_text(line) { - return Some(format!("{BOLD}{}{}", strip_inline_marks(title), RESET)); - } - - if let Some(item) = list_item(line) { - return Some(format!("- {}", strip_inline_marks(item))); - } - - if let Some(quote) = line.strip_prefix("> ") { - return Some(format!("{DIM}> {}{}", strip_inline_marks(quote), RESET)); - } - - Some(strip_inline_marks(line)) -} - -fn render_prefixed(text: &str, prefix: &str) -> String { - text.lines() - .map(|line| format!("{prefix}{line}")) - .collect::>() - .join("\n") -} - -fn heading_text(line: &str) -> Option<&str> { - let level = line.chars().take_while(|ch| *ch == '#').count(); - - if !(1..=6).contains(&level) { - return None; - } - - let title = line[level..].trim_start(); - (!title.is_empty()).then_some(title) -} - -fn list_item(line: &str) -> Option<&str> { - line.strip_prefix("- ") - .or_else(|| line.strip_prefix("* ")) - .or_else(|| line.strip_prefix("+ ")) -} - -fn strip_inline_marks(text: &str) -> String { - text.replace("**", "").replace('`', "") -} - -pub fn tool_call_summary(call: &ToolCall) -> String { - let Some(obj) = call.arguments.as_object() else { - return truncate_one_line(&call.arguments.to_string(), 120); - }; - match call.name.as_str() { - "generate_image" => join_summary_parts(&[ - string_field_value(obj.get("label")), - string_field_value(obj.get("size")), - short_field_value(obj.get("prompt"), 110), - count_field_value(obj.get("reference_images"), "refs"), - ]), - "save_artifact" => join_summary_parts(&[ - string_field_value(obj.get("slug")), - short_path(&string_field_value(obj.get("image_path"))), - ]), - "register_asset" => join_summary_parts(&[ - string_field_value(obj.get("space_id")), - first_non_empty(&[ - string_field_value(obj.get("id")), - string_field_value(obj.get("kind")), - ]), - short_field_value(obj.get("description"), 90), - ]), - "get_context_packet" => join_summary_parts(&[ - string_field_value(obj.get("space_id")), - short_field_value(obj.get("query"), 100), - ]), - "bash" => join_summary_parts(&[ - short_field_value(obj.get("command"), 110), - short_field_value(obj.get("description"), 80), - ]), - "read_file" | "get_character" | "get_reference" => first_non_empty(&[ - string_field_value(obj.get("path")), - string_field_value(obj.get("slug")), - ]), - "search" | "list_spaces" | "list_characters" | "list_references" => { - short_field_value(obj.get("query"), 100) - } - "create_space" - | "activate_space" - | "record_decision" - | "record_feedback" - | "record_memory_item" - | "promote_memory_item" - | "create_episode" - | "update_asset_weight" - | "record_compaction" - | "compile_skill" => fallback_arg_summary(obj, &call.arguments), - "finish" => short_field_value(obj.get("summary"), 110), - _ => fallback_arg_summary(obj, &call.arguments), - } -} - -pub fn tool_result_summary(tool_name: &str, content: &str) -> String { - if content.trim().is_empty() { - return "(no output)".to_string(); - } - if tool_name == "finish" { - if let Some(obj) = json_object_str(content) { - return join_summary_parts(&[ - truncate_one_line(&string_field(&obj, "summary"), 120), - artifacts_count(&obj), - ]); - } - } - if let Some(obj) = json_object_str(content) { - if let Some(summary) = tool_result_object_summary(tool_name, &obj) { - return summary; - } - let path = string_field(&obj, "path"); - if !path.is_empty() { - return match tool_name { - "generate_image" => format!("saved {}", short_path(&path)), - "save_artifact" => format!("artifact {}", short_path(&path)), - _ => short_path(&path), - }; - } - let id = string_field(&obj, "id"); - if !id.is_empty() { - return format!("ok {id}"); - } - let summary = string_field(&obj, "summary"); - if !summary.is_empty() { - return truncate_one_line(&summary, 140); - } - if let Some(ok) = obj.get("ok") { - return format!("ok {ok}"); - } - } - if let Some(arr) = json_array_objects(content) { - if matches!( - tool_name, - "search" | "list_spaces" | "list_characters" | "list_references" - ) { - return format!("{} item(s)", arr.len()); - } - if let Some(first) = arr.first() { - let path = string_field(first, "path"); - if !path.is_empty() { - if arr.len() == 1 { - return format!("saved {}", short_path(&path)); - } - return format!("saved {} files, first {}", arr.len(), short_path(&path)); - } - let id = string_field(first, "id"); - if !id.is_empty() { - if arr.len() == 1 { - return format!("ok {id}"); - } - return format!("ok {} items, first {id}", arr.len()); - } - } - } - truncate_one_line(content, 180) -} - -fn tool_result_object_summary( - tool_name: &str, - obj: &serde_json::Map, -) -> Option { - match tool_name { - "get_context_packet" => { - let space_id = obj - .get("space") - .and_then(Value::as_object) - .map(|space| string_field(space, "id")) - .filter(|id| !id.is_empty()) - .or_else(|| { - let id = string_field(obj, "project_id"); - (!id.is_empty()).then_some(id) - }) - .unwrap_or_else(|| "context".to_string()); - let assets = array_len(obj.get("assets")); - let decisions = array_len(obj.get("recent_decisions")); - let feedback = array_len(obj.get("recent_feedback")); - let episodes = array_len(obj.get("recent_episodes")); - return Some(join_summary_parts(&[ - space_id, - count_text(assets, "asset(s)"), - count_text(episodes, "episode(s)"), - count_text(decisions, "decision(s)"), - count_text(feedback, "feedback"), - ])); - } - "bash" => { - let exit = obj - .get("exit_code") - .map(|value| string_field_value(Some(value))) - .unwrap_or_default(); - let stdout_len = obj - .get("stdout") - .and_then(Value::as_str) - .map(|s| s.chars().count()) - .unwrap_or(0); - let stderr = obj - .get("stderr") - .and_then(Value::as_str) - .map(truncate_error) - .unwrap_or_default(); - let mut parts = vec![format!( - "exit {}", - if exit.is_empty() { "0" } else { &exit } - )]; - if stdout_len > 0 { - parts.push(format!("{stdout_len} stdout chars")); - } - if !stderr.is_empty() { - parts.push(format!("stderr {stderr}")); - } - return Some(join_summary_parts(&parts)); - } - "read_file" => { - let path = string_field(obj, "path"); - let chars = obj - .get("content") - .and_then(Value::as_str) - .map(|s| s.chars().count()) - .unwrap_or(0); - return Some(join_summary_parts(&[ - short_path(&path), - count_text(chars, "char(s)"), - ])); - } - "create_space" => { - return Some(join_summary_parts(&[ - string_field(obj, "id"), - string_field(obj, "status"), - ])); - } - "activate_space" => { - let id = obj - .get("space") - .and_then(Value::as_object) - .map(|space| string_field(space, "id")) - .unwrap_or_default(); - return Some(join_summary_parts(&["active".to_string(), id])); - } - "record_decision" | "promote_memory_item" => { - return Some(join_summary_parts(&[ - string_field(obj, "id"), - short_field_value(obj.get("decision"), 100), - ])); - } - "record_feedback" => { - return Some(join_summary_parts(&[ - string_field(obj, "id"), - string_field(obj, "signal"), - ])); - } - "record_memory_item" => { - return Some(join_summary_parts(&[ - string_field(obj, "id"), - string_field(obj, "kind"), - string_field(obj, "status"), - ])); - } - "create_episode" => { - return Some(join_summary_parts(&[ - string_field(obj, "id"), - string_field(obj, "status"), - ])); - } - "update_asset_weight" => { - return Some(join_summary_parts(&[ - string_field(obj, "id"), - string_field(obj, "status"), - obj.get("weight") - .map(|value| string_field_value(Some(value))) - .unwrap_or_default(), - ])); - } - "record_compaction" => { - return Some(join_summary_parts(&[ - string_field(obj, "id"), - short_field_value(obj.get("summary"), 100), - ])); - } - "compile_skill" => { - return Some(join_summary_parts(&[ - string_field(obj, "skill"), - count_text( - obj.get("prompt") - .and_then(Value::as_str) - .map(|s| s.chars().count()) - .unwrap_or(0), - "prompt chars", - ), - ])); - } - _ => {} - } - None -} - -pub fn tool_error_message(content: &str) -> Option { - let obj = json_object_str(content)?; - let err = obj.get("error")?; - let msg = string_field_value(Some(err)); - (!msg.is_empty()).then_some(msg) -} - -fn json_object_str(content: &str) -> Option> { - serde_json::from_str::(content) - .ok() - .and_then(|value| value.as_object().cloned()) -} - -fn json_array_objects(content: &str) -> Option>> { - serde_json::from_str::(content) - .ok() - .and_then(|value| value.as_array().cloned()) - .map(|items| { - items - .into_iter() - .filter_map(|value| value.as_object().cloned()) - .collect::>() - }) - .filter(|items| !items.is_empty()) -} - -fn string_field(obj: &serde_json::Map, key: &str) -> String { - string_field_value(obj.get(key)) -} - -fn string_field_value(value: Option<&Value>) -> String { - match value { - Some(Value::String(s)) => s.trim().to_string(), - Some(Value::Number(n)) => n.to_string(), - Some(Value::Bool(v)) => v.to_string(), - Some(other) => other.to_string(), - None => String::new(), - } -} - -fn short_field_value(value: Option<&Value>, limit: usize) -> String { - truncate_one_line(&string_field_value(value), limit) -} - -fn count_field_value(value: Option<&Value>, label: &str) -> String { - match value { - Some(Value::Array(items)) if !items.is_empty() => format!("{} {label}", items.len()), - _ => String::new(), - } -} - -fn array_len(value: Option<&Value>) -> usize { - value.and_then(Value::as_array).map(Vec::len).unwrap_or(0) -} - -fn count_text(count: usize, label: &str) -> String { - if count == 0 { - String::new() - } else { - format!("{count} {label}") - } -} - -fn fallback_arg_summary(obj: &serde_json::Map, raw: &Value) -> String { - for key in [ - "name", - "id", - "title", - "space_id", - "query", - "path", - "command", - "summary", - "description", - ] { - let value = short_field_value(obj.get(key), 100); - if !value.is_empty() { - return value; - } - } - truncate_one_line(&raw.to_string(), 120) -} - -fn artifact_strings(obj: &serde_json::Map) -> Vec { - match obj.get("artifacts") { - Some(Value::Array(items)) => items - .iter() - .map(|item| string_field_value(Some(item))) - .filter(|item| !item.is_empty()) - .collect(), - _ => Vec::new(), - } -} - -fn artifacts_count(obj: &serde_json::Map) -> String { - let count = artifact_strings(obj).len(); - if count == 0 { - String::new() - } else { - format!("{count} artifact(s)") - } -} - -fn join_summary_parts(parts: &[String]) -> String { - parts - .iter() - .map(|part| part.trim()) - .filter(|part| !part.is_empty()) - .collect::>() - .join(" · ") -} - -fn first_non_empty(parts: &[String]) -> String { - parts - .iter() - .find(|part| !part.trim().is_empty()) - .cloned() - .unwrap_or_default() -} - -fn short_path(path: &str) -> String { - let path = path.trim(); - if path.is_empty() { - return String::new(); - } - let path = PathBuf::from(path); - let base = path - .file_name() - .and_then(|v| v.to_str()) - .unwrap_or_default(); - let dir = path - .parent() - .and_then(Path::file_name) - .and_then(|v| v.to_str()) - .unwrap_or_default(); - if dir.is_empty() { - base.to_string() - } else { - format!("{dir}/{base}") - } -} - -fn truncate_one_line(value: &str, limit: usize) -> String { - let line = value.split_whitespace().collect::>().join(" "); - if line.chars().count() <= limit { - return line; - } - let mut out = line.chars().take(limit).collect::(); - out.push('…'); - out -} - -fn truncate_error(value: &str) -> String { - truncate_one_line(value, 80) -} - -fn push_line(out: &mut String, line: &str) { - out.push_str(line); - out.push('\n'); -} - -fn strip_ansi(value: &str) -> String { - let mut out = String::new(); - let mut chars = value.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' && chars.peek() == Some(&'[') { - let _ = chars.next(); - for c in chars.by_ref() { - if c.is_ascii_alphabetic() { - break; - } - } - continue; - } - out.push(ch); - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn markdown_renderer_preserves_blocks() { - let rendered = render_markdown("# Title\n\n- one\n- two", 40); - - assert!(rendered.contains("Title")); - assert!(rendered.contains("- one")); - assert!(rendered.contains("- two")); - } - - #[test] - fn tool_block_indents_body_after_marker() { - let rendered = render_block( - &Block { - kind: BlockKind::Tool, - body: "tool output".to_string(), - }, - 40, - ); - - assert!(rendered.contains("● ")); - assert!(rendered.contains("tool output")); - } - - #[test] - fn tool_call_renderer_matches_go_semantics() { - let call = ToolCall { - id: "call-1".to_string(), - name: "save_artifact".to_string(), - arguments: serde_json::json!({ - "slug": "ep-002-mothers-day", - "image_path": "/home/wangzhi.wit/bigone/.openmelon/artifacts/ep-002-mothers-day/20260512-032739/image.png", - "sha256": "should-not-leak-into-ui", - }), - }; - - let rendered = render_tool_call(&call, TranscriptMode::Plain); - - assert!(rendered.contains("● save_artifact")); - assert!(rendered.contains("ep-002-mothers-day")); - assert!(rendered.contains("20260512-032739/image.png")); - assert!(!rendered.contains("sha256")); - assert!(!rendered.contains('{')); - } - - #[test] - fn tool_result_renderer_summarizes_artifacts() { - let rendered = render_tool_result( - "save_artifact", - r#"{"path":"/home/wangzhi.wit/bigone/outputs/ep-002/20260512-032739/image.png","sha256":"abc"}"#, - TranscriptMode::Plain, - ); - - assert_eq!(rendered, " └ artifact 20260512-032739/image.png"); - } - - #[test] - fn finish_result_renders_as_assistant_text() { - let rendered = render_finish_result( - r#"{"summary":"Done with **three** images.","artifacts":["/tmp/a.png"]}"#, - 88, - TranscriptMode::Plain, - ); - - assert!(rendered.contains(" Done with three images.")); - assert!(rendered.contains(" artifact: tmp/a.png")); - assert!(!rendered.contains("tool finish")); - } - - #[test] - fn history_renderer_maps_tool_ids_and_skips_finish_call() { - let history = vec![ - Message { - role: Role::User, - content: "继续".to_string(), - tool_calls: Vec::new(), - tool_call_id: String::new(), - }, - Message { - role: Role::Assistant, - content: String::new(), - tool_calls: vec![ - ToolCall { - id: "call-save".to_string(), - name: "save_artifact".to_string(), - arguments: serde_json::json!({ - "slug": "ep-002", - "image_path": "/home/wangzhi.wit/bigone/outputs/ep-002/page-1.png", - }), - }, - ToolCall { - id: "call-finish".to_string(), - name: "finish".to_string(), - arguments: serde_json::json!({"summary":"ok"}), - }, - ], - tool_call_id: String::new(), - }, - Message { - role: Role::Tool, - content: r#"{"path":"/home/wangzhi.wit/bigone/outputs/ep-002/page-1.png"}"# - .to_string(), - tool_calls: Vec::new(), - tool_call_id: "call-save".to_string(), - }, - Message { - role: Role::Tool, - content: r#"{"summary":"完成","artifacts":["/tmp/page-1.png"]}"#.to_string(), - tool_calls: Vec::new(), - tool_call_id: "call-finish".to_string(), - }, - ]; - - let rendered = render_history(&history, 88, TranscriptMode::Plain); - - assert!(rendered.contains("prior conversation (4 messages)")); - assert!(rendered.contains("> 继续")); - assert!(rendered.contains("● save_artifact")); - assert!(rendered.contains("└ artifact ep-002/page-1.png")); - assert!(rendered.contains(" 完成")); - assert!(rendered.contains("continue below")); - assert!(!rendered.contains("● finish")); - assert!(!rendered.contains("sha256")); - } - - #[test] - fn context_and_bash_results_have_product_summaries() { - let context = render_tool_result( - "get_context_packet", - r#"{"project_id":"bigone","space":{"id":"restaurant-daily-comics"},"assets":[{},{}],"recent_episodes":[{}],"recent_decisions":[{},{}],"recent_feedback":[{}],"canon":"long text"}"#, - TranscriptMode::Plain, - ); - let bash = render_tool_result( - "bash", - r#"{"exit_code":0,"stdout":"abc\n","stderr":"","approved_via":"user-approved"}"#, - TranscriptMode::Plain, - ); - - assert_eq!( - context, - " └ restaurant-daily-comics · 2 asset(s) · 1 episode(s) · 2 decision(s) · 1 feedback" - ); - assert_eq!(bash, " └ exit 0 · 4 stdout chars"); - assert!(!context.contains("canon")); - assert!(!bash.contains("approved_via")); - } - - #[test] - fn markdown_buffer_waits_for_stable_boundary() { - let mut buffer = "hello".to_string(); - - assert!(flush_markdown_buffer(&mut buffer, false, 88, TranscriptMode::Plain).is_none()); - buffer.push_str("\n\n"); - let rendered = flush_markdown_buffer(&mut buffer, false, 88, TranscriptMode::Plain) - .expect("stable block"); - - assert_eq!(rendered, " hello"); - assert!(buffer.is_empty()); - } -} diff --git a/rust/crates/openmelon-tui/src/runtime.rs b/rust/crates/openmelon-tui/src/runtime.rs deleted file mode 100644 index 39d293c..0000000 --- a/rust/crates/openmelon-tui/src/runtime.rs +++ /dev/null @@ -1,349 +0,0 @@ -use anyhow::{Context, Result}; -use serde_json::Value; -use std::sync::mpsc; - -use crate::llm::{ChatRequest, FinishReason, Message, OpenAIClient, Role, ToolCall, Usage}; -use crate::render::{ - flush_markdown_buffer, render_finish_result, render_tool_call, render_tool_result, - TranscriptMode, -}; -use crate::session::Session; -use crate::tools::{ToolEnv, ToolRegistry}; - -pub struct Runtime { - pub llm: OpenAIClient, - pub registry: ToolRegistry, - pub env: ToolEnv, - pub max_steps: usize, - pub reasoning_effort: String, - pub drain_user_input: Option Vec + Send>>, - pub events: Option>, -} - -#[derive(Debug, Clone)] -pub enum RuntimeEvent { - TurnStart { - step: usize, - }, - TextDelta(String), - ToolCall(ToolCall), - ToolResult { - tool_name: String, - content: String, - error: Option, - }, - TurnEnd { - step: usize, - finish: FinishReason, - usage: Usage, - }, - QueuedInputApplied { - count: usize, - }, -} - -pub struct RunInput { - pub system_prompt: String, - pub user_input: String, - pub history: Vec, -} - -pub struct RunResult { - pub messages: Vec, - pub steps: usize, - pub finished: bool, - pub finish_summary: String, - pub finish_artifacts: Vec, -} - -impl Runtime { - pub fn run(&mut self, input: RunInput, session: &mut Session) -> Result { - let mut messages = if input.history.is_empty() { - let mut seeded = Vec::new(); - if !input.system_prompt.trim().is_empty() { - seeded.push(Message { - role: Role::System, - content: input.system_prompt, - tool_calls: Vec::new(), - tool_call_id: String::new(), - }); - } - seeded - } else { - input.history - }; - if !input.user_input.trim().is_empty() { - messages.push(Message { - role: Role::User, - content: input.user_input, - tool_calls: Vec::new(), - tool_call_id: String::new(), - }); - } - - let mut result = RunResult { - messages: Vec::new(), - steps: 0, - finished: false, - finish_summary: String::new(), - finish_artifacts: Vec::new(), - }; - - let max_steps = self.max_steps.max(1); - for step in 0..max_steps { - result.steps = step + 1; - let drained = self.drain_user_input(); - if !drained.is_empty() { - let count = drained.len(); - for text in drained { - messages.push(Message { - role: Role::User, - content: text, - tool_calls: Vec::new(), - tool_call_id: String::new(), - }); - } - self.emit(RuntimeEvent::QueuedInputApplied { count }); - } - self.emit(RuntimeEvent::TurnStart { step: step + 1 }); - session.append_event( - "turn_start", - serde_json::json!({ - "step": step + 1, - "status": "started", - }), - )?; - - let mut markdown_buffer = String::new(); - let events_tx = self.events.clone(); - let response = self - .llm - .stream_chat( - ChatRequest { - messages: messages.clone(), - tools: self.registry.specs(), - reasoning_effort: self.reasoning_effort.clone(), - }, - |delta| { - if let Some(tx) = &events_tx { - let _ = tx.send(RuntimeEvent::TextDelta(delta.to_string())); - } else { - markdown_buffer.push_str(delta); - flush_runtime_markdown(&mut markdown_buffer, false); - } - }, - ) - .with_context(|| format!("runtime chat step {}", step + 1))?; - - if self.events.is_none() { - flush_runtime_markdown(&mut markdown_buffer, true); - } - session.append_event( - "turn_response", - serde_json::json!({ - "step": step + 1, - "status": "received", - "detail": { - "finish": format!("{:?}", response.finish_reason), - "tool_calls": response.message.tool_calls.len(), - "usage": { - "prompt_tokens": response.usage.prompt_tokens, - "completion_tokens": response.usage.completion_tokens, - "total_tokens": response.usage.total_tokens, - } - } - }), - )?; - - let tool_calls = response.message.tool_calls.clone(); - let finish_reason = response.finish_reason; - let usage = response.usage; - messages.push(response.message); - if tool_calls.is_empty() { - self.emit(RuntimeEvent::TurnEnd { - step: step + 1, - finish: finish_reason, - usage, - }); - result.finished = matches!(finish_reason, FinishReason::Stop | FinishReason::Other); - result.messages = messages; - return Ok(result); - } - - for call in tool_calls { - session.append_event( - "tool_call", - serde_json::json!({ - "step": step + 1, - "tool": call.name, - "status": "started", - "detail": { "arguments": call.arguments }, - }), - )?; - if call.name != "finish" { - if self.events.is_some() { - self.emit(RuntimeEvent::ToolCall(call.clone())); - } else { - flush_runtime_markdown(&mut markdown_buffer, true); - println!(); - println!("{}", render_tool_call(&call, TranscriptMode::Styled)); - } - } - let (content, dispatch_err) = - match self - .registry - .dispatch(&self.env, &call.name, call.arguments.clone()) - { - Ok(value) => (value, None), - Err(err) => ( - serde_json::json!({ "error": err.to_string() }), - Some(err.to_string()), - ), - }; - if let Some(err) = dispatch_err { - let content_for_render = - serde_json::json!({ "error": err.clone() }).to_string(); - if self.events.is_some() { - self.emit(RuntimeEvent::ToolResult { - tool_name: call.name.clone(), - content: content_for_render.clone(), - error: Some(err.clone()), - }); - } else { - println!( - "{}", - render_tool_result( - &call.name, - &content_for_render, - TranscriptMode::Styled, - ) - ); - } - session.append_event( - "tool_result", - serde_json::json!({ - "step": step + 1, - "tool": call.name, - "status": "error", - "detail": { "error": err }, - }), - )?; - } else if call.name == "finish" { - if let Some(summary) = content.get("summary").and_then(Value::as_str) { - result.finish_summary = summary.to_string(); - } - if let Some(artifacts) = content.get("artifacts").and_then(Value::as_array) { - result.finish_artifacts = artifacts - .iter() - .filter_map(|v| v.as_str().map(ToString::to_string)) - .collect(); - } - let finish = serde_json::to_string(&content)?; - if self.events.is_some() { - self.emit(RuntimeEvent::ToolResult { - tool_name: call.name.clone(), - content: finish.clone(), - error: None, - }); - } else { - let rendered = render_finish_result(&finish, 88, TranscriptMode::Styled); - if !rendered.trim().is_empty() { - println!("{rendered}"); - } - } - session.append_event( - "tool_result", - serde_json::json!({ - "step": step + 1, - "tool": call.name, - "status": "ok", - "detail": content, - }), - )?; - } else { - let content_for_render = serde_json::to_string(&content)?; - if self.events.is_some() { - self.emit(RuntimeEvent::ToolResult { - tool_name: call.name.clone(), - content: content_for_render.clone(), - error: None, - }); - } else { - println!( - "{}", - render_tool_result( - &call.name, - &content_for_render, - TranscriptMode::Styled, - ) - ); - } - session.append_event( - "tool_result", - serde_json::json!({ - "step": step + 1, - "tool": call.name, - "status": if content.get("error").is_some() { "error" } else { "ok" }, - "detail": content, - }), - )?; - } - - messages.push(Message { - role: Role::Tool, - content: serde_json::to_string(&content)?, - tool_calls: Vec::new(), - tool_call_id: call.id, - }); - - if call.name == "finish" { - self.emit(RuntimeEvent::TurnEnd { - step: step + 1, - finish: finish_reason, - usage, - }); - result.finished = true; - result.messages = messages; - return Ok(result); - } - } - self.emit(RuntimeEvent::TurnEnd { - step: step + 1, - finish: finish_reason, - usage, - }); - } - - result.messages = messages; - Ok(result) - } - - fn drain_user_input(&mut self) -> Vec { - let Some(drain) = &mut self.drain_user_input else { - return Vec::new(); - }; - drain() - .into_iter() - .map(|text| text.trim().to_string()) - .filter(|text| !text.is_empty()) - .collect() - } - - fn emit(&self, event: RuntimeEvent) { - if let Some(tx) = &self.events { - let _ = tx.send(event); - } - } -} - -fn flush_runtime_markdown(buffer: &mut String, force: bool) { - let Some(rendered) = flush_markdown_buffer(buffer, force, 88, TranscriptMode::Styled) else { - return; - }; - if rendered.trim().is_empty() { - return; - } - println!("{rendered}"); - println!(); - let _ = std::io::Write::flush(&mut std::io::stdout()); -} diff --git a/rust/crates/openmelon-tui/src/session.rs b/rust/crates/openmelon-tui/src/session.rs deleted file mode 100644 index 4477bca..0000000 --- a/rust/crates/openmelon-tui/src/session.rs +++ /dev/null @@ -1,306 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use time::format_description::well_known::Rfc3339; -use time::{macros::format_description, OffsetDateTime}; - -use crate::llm::Message; - -#[derive(Debug, Clone)] -pub struct ProjectLayout { - root: PathBuf, - state_dir: PathBuf, - outputs_dir: PathBuf, -} - -impl ProjectLayout { - pub fn discover(workdir: impl AsRef) -> Result { - let root = workdir - .as_ref() - .canonicalize() - .with_context(|| format!("resolve project root {}", workdir.as_ref().display()))?; - let state_dir = root.join(".openmelon"); - let outputs_dir = root.join("outputs"); - - fs::create_dir_all(&state_dir) - .with_context(|| format!("create state dir {}", state_dir.display()))?; - fs::create_dir_all(&outputs_dir) - .with_context(|| format!("create outputs dir {}", outputs_dir.display()))?; - - Ok(Self { - root, - state_dir, - outputs_dir, - }) - } - - pub fn root(&self) -> &Path { - &self.root - } - - pub fn history_file(&self) -> PathBuf { - self.state_dir.join("rust-tui-history.txt") - } - - pub fn outputs_dir(&self) -> &Path { - &self.outputs_dir - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Meta { - pub version: u8, - pub id: String, - pub project_id: String, - pub intent: String, - pub started_at: String, - #[serde(default)] - pub workspace_root: String, - #[serde(default)] - pub provider: String, - #[serde(default)] - pub model: String, - #[serde(default)] - pub resumed_from: String, -} - -pub struct Session { - pub id: String, - pub dir: PathBuf, - workdir: PathBuf, - project_id: String, - intent: String, - started_at: OffsetDateTime, - provider: String, - model: String, - resumed_from: String, - messages: File, -} - -impl Session { - pub fn create( - workdir: &Path, - project_id: &str, - intent: &str, - resumed_from: Option<&str>, - ) -> Result { - let now = OffsetDateTime::now_utc(); - let id = format!( - "{}-{}", - now.format(format_description!( - "[year][month][day]-[hour][minute][second]" - ))?, - short_id() - ); - let dir = workdir.join(".openmelon").join("sessions").join(&id); - fs::create_dir_all(&dir) - .with_context(|| format!("create session dir {}", dir.display()))?; - - let messages = OpenOptions::new() - .create(true) - .append(true) - .open(dir.join("messages.jsonl")) - .with_context(|| format!("open {}", dir.join("messages.jsonl").display()))?; - - let session = Self { - id, - dir, - workdir: workdir.to_path_buf(), - project_id: project_id.to_string(), - intent: intent.to_string(), - started_at: now, - provider: String::new(), - model: String::new(), - resumed_from: resumed_from.unwrap_or_default().to_string(), - messages, - }; - session.write_meta()?; - Ok(session) - } - - pub fn set_runtime_info(&mut self, provider: &str, model: &str) -> Result<()> { - self.provider = provider.to_string(); - self.model = model.to_string(); - self.write_meta() - } - - pub fn fork_writer(&self) -> Result { - let messages = OpenOptions::new() - .create(true) - .append(true) - .open(self.dir.join("messages.jsonl")) - .with_context(|| format!("open {}", self.dir.join("messages.jsonl").display()))?; - Ok(Self { - id: self.id.clone(), - dir: self.dir.clone(), - workdir: self.workdir.clone(), - project_id: self.project_id.clone(), - intent: self.intent.clone(), - started_at: self.started_at, - provider: self.provider.clone(), - model: self.model.clone(), - resumed_from: self.resumed_from.clone(), - messages, - }) - } - - pub fn append_prompt(&self, kind: &str, content: &str) -> Result<()> { - if content.trim().is_empty() { - return Ok(()); - } - append_jsonl( - &self.dir.join("prompt_history.jsonl"), - &serde_json::json!({ - "at": OffsetDateTime::now_utc().format(&Rfc3339)?, - "kind": if kind.trim().is_empty() { "user" } else { kind }, - "content": content.trim(), - }), - ) - } - - #[allow(dead_code)] - pub fn append_event(&self, event_type: &str, value: serde_json::Value) -> Result<()> { - let mut obj = match value { - serde_json::Value::Object(map) => map, - other => { - let mut map = serde_json::Map::new(); - map.insert("detail".to_string(), other); - map - } - }; - obj.insert( - "at".to_string(), - serde_json::json!(OffsetDateTime::now_utc().format(&Rfc3339)?), - ); - obj.insert("type".to_string(), serde_json::json!(event_type)); - append_jsonl( - &self.dir.join("events.jsonl"), - &serde_json::Value::Object(obj), - ) - } - - pub fn append_messages(&mut self, messages: &[Message]) -> Result<()> { - for message in messages { - serde_json::to_writer(&mut self.messages, message)?; - self.messages.write_all(b"\n")?; - } - self.messages.flush()?; - Ok(()) - } - - pub fn write_summary(&self, summary: &str, artifacts: &[String], finished: bool) -> Result<()> { - let body = serde_json::json!({ - "id": self.id, - "finished": finished, - "summary": summary, - "artifacts": artifacts, - "finished_at": OffsetDateTime::now_utc().format(&Rfc3339)?, - }); - fs::write( - self.dir.join("summary.json"), - serde_json::to_string_pretty(&body)? + "\n", - )?; - Ok(()) - } - - fn write_meta(&self) -> Result<()> { - let meta = Meta { - version: 2, - id: self.id.clone(), - project_id: self.project_id.clone(), - intent: self.intent.clone(), - started_at: self.started_at.format(&Rfc3339)?, - workspace_root: self.workdir.display().to_string(), - provider: self.provider.clone(), - model: self.model.clone(), - resumed_from: self.resumed_from.clone(), - }; - fs::write( - self.dir.join("meta.json"), - serde_json::to_string_pretty(&meta)? + "\n", - )?; - Ok(()) - } -} - -pub fn load_history(workdir: &Path, session_id: &str) -> Result> { - let path = workdir - .join(".openmelon") - .join("sessions") - .join(session_id) - .join("messages.jsonl"); - let file = File::open(&path).with_context(|| format!("open {}", path.display()))?; - let reader = BufReader::new(file); - let mut out = Vec::new(); - for line in reader.lines() { - let line = line?; - if line.trim().is_empty() { - continue; - } - out.push(serde_json::from_str(&line).with_context(|| format!("parse {}", path.display()))?); - } - Ok(out) -} - -pub fn load_events(session_dir: &Path, limit: usize) -> Result> { - let path = session_dir.join("events.jsonl"); - if !path.exists() { - return Ok(Vec::new()); - } - let file = File::open(&path).with_context(|| format!("open {}", path.display()))?; - let reader = BufReader::new(file); - let mut rows = Vec::new(); - for line in reader.lines() { - let line = line?; - if line.trim().is_empty() { - continue; - } - rows.push(serde_json::from_str(&line)?); - } - if limit > 0 && rows.len() > limit { - Ok(rows.split_off(rows.len() - limit)) - } else { - Ok(rows) - } -} - -fn append_jsonl(path: &Path, value: &serde_json::Value) -> Result<()> { - let mut file = OpenOptions::new().create(true).append(true).open(path)?; - serde_json::to_writer(&mut file, value)?; - file.write_all(b"\n")?; - Ok(()) -} - -fn short_id() -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - std::time::SystemTime::now().hash(&mut hasher); - std::process::id().hash(&mut hasher); - format!("{:08x}", hasher.finish() as u32) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn project_layout_keeps_state_hidden_and_outputs_visible() { - let root = PathBuf::from("/tmp/openmelon-example"); - let layout = ProjectLayout { - state_dir: root.join(".openmelon"), - outputs_dir: root.join("outputs"), - root: root.clone(), - }; - - assert_eq!( - layout.history_file(), - root.join(".openmelon/rust-tui-history.txt") - ); - assert_eq!(layout.outputs_dir(), root.join("outputs")); - } -} diff --git a/rust/crates/openmelon-tui/src/terminal.rs b/rust/crates/openmelon-tui/src/terminal.rs deleted file mode 100644 index c86d0f6..0000000 --- a/rust/crates/openmelon-tui/src/terminal.rs +++ /dev/null @@ -1,290 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use rustyline::completion::{Completer, Pair}; -use rustyline::config::{BellStyle, CompletionType}; -use rustyline::error::ReadlineError; -use rustyline::highlight::Highlighter; -use rustyline::hint::{Hint, Hinter}; -use rustyline::validate::Validator; -use rustyline::{ - Cmd, Config, Context as RustyContext, Editor, Event, Helper, KeyCode, KeyEvent, Modifiers, -}; - -pub enum Input { - Line(String), - Interrupted, - Eof, -} - -pub struct LineEditor { - editor: Editor, - history_file: PathBuf, -} - -impl LineEditor { - pub fn new(history_file: PathBuf) -> Result { - let config = Config::builder() - .completion_type(CompletionType::List) - .bell_style(BellStyle::None) - .auto_add_history(false) - .build(); - let mut editor = Editor::with_config(config).context("create line editor")?; - editor.set_helper(Some(OpenMelonHelper::new())); - editor.bind_sequence(KeyEvent::ctrl('J'), Cmd::Newline); - editor.bind_sequence( - Event::KeySeq(vec![KeyEvent(KeyCode::Enter, Modifiers::SHIFT)]), - Cmd::Newline, - ); - let _ = editor.load_history(&history_file); - - Ok(Self { - editor, - history_file, - }) - } - - pub fn read(&mut self, prompt: &str) -> Result { - match self.editor.readline(prompt) { - Ok(line) => { - if !line.trim().is_empty() { - let _ = self.editor.add_history_entry(line.as_str()); - let _ = self.editor.save_history(&self.history_file); - } - - Ok(Input::Line(line)) - } - Err(ReadlineError::Interrupted) => Ok(Input::Interrupted), - Err(ReadlineError::Eof) => Ok(Input::Eof), - Err(err) => Err(err).context("read input"), - } - } -} - -struct OpenMelonHelper { - commands: Vec, -} - -struct SlashCommand { - name: &'static str, - hint: &'static str, -} - -struct OpenMelonHint { - display: String, - completion: String, -} - -impl OpenMelonHelper { - fn new() -> Self { - Self { - commands: slash_commands(), - } - } -} - -impl Completer for OpenMelonHelper { - type Candidate = Pair; - - fn complete( - &self, - line: &str, - pos: usize, - _ctx: &RustyContext<'_>, - ) -> rustyline::Result<(usize, Vec)> { - let Some(prefix) = slash_prefix(line, pos) else { - return Ok((0, Vec::new())); - }; - let candidates = self - .commands - .iter() - .filter(|command| command.name.starts_with(prefix)) - .map(|command| Pair { - display: format!("{:<13} {}", command.name, command.hint), - replacement: command.name.to_string(), - }) - .collect::>(); - Ok((0, candidates)) - } -} - -impl Hinter for OpenMelonHelper { - type Hint = OpenMelonHint; - - fn hint(&self, line: &str, pos: usize, _ctx: &RustyContext<'_>) -> Option { - if line.is_empty() && pos == 0 { - return Some(OpenMelonHint { - display: "Ask OpenMelon".to_string(), - completion: String::new(), - }); - } - let prefix = slash_prefix(line, pos)?; - if prefix == "/" { - let names = self - .commands - .iter() - .take(8) - .map(|command| command.name) - .collect::>() - .join(" "); - return Some(OpenMelonHint { - display: format!(" {names}"), - completion: String::new(), - }); - } - self.commands - .iter() - .find(|command| command.name.starts_with(prefix) && command.name != prefix) - .map(|command| OpenMelonHint { - display: command.name[prefix.len()..].to_string(), - completion: command.name[prefix.len()..].to_string(), - }) - } -} - -impl Hint for OpenMelonHint { - fn display(&self) -> &str { - &self.display - } - - fn completion(&self) -> Option<&str> { - if self.completion.is_empty() { - None - } else { - Some(&self.completion) - } - } -} - -impl Highlighter for OpenMelonHelper {} - -impl Validator for OpenMelonHelper {} - -impl Helper for OpenMelonHelper {} - -fn slash_prefix(line: &str, pos: usize) -> Option<&str> { - if pos != line.len() { - return None; - } - if !line.starts_with('/') || line.contains(char::is_whitespace) { - return None; - } - Some(line) -} - -fn slash_commands() -> Vec { - vec![ - SlashCommand { - name: "/help", - hint: "show commands", - }, - SlashCommand { - name: "/status", - hint: "show project/model status", - }, - SlashCommand { - name: "/history", - hint: "render conversation history", - }, - SlashCommand { - name: "/clear", - hint: "clear in-memory history", - }, - SlashCommand { - name: "/session", - hint: "print session directory", - }, - SlashCommand { - name: "/save", - hint: "save history as JSONL", - }, - SlashCommand { - name: "/copy", - hint: "copy transcript via OSC52", - }, - SlashCommand { - name: "/events", - hint: "show recent session events", - }, - SlashCommand { - name: "/model", - hint: "switch LLM model", - }, - SlashCommand { - name: "/model-image", - hint: "switch image model", - }, - SlashCommand { - name: "/settings", - hint: "change settings", - }, - SlashCommand { - name: "/skill", - hint: "apply a skillplus package", - }, - SlashCommand { - name: "/space", - hint: "show creative space summary", - }, - SlashCommand { - name: "/compact", - hint: "print compaction draft", - }, - SlashCommand { - name: "/exit", - hint: "exit", - }, - SlashCommand { - name: "/quit", - hint: "exit", - }, - SlashCommand { - name: "/q", - hint: "exit", - }, - ] -} - -#[cfg(test)] -mod tests { - use super::*; - use rustyline::history::DefaultHistory; - - #[test] - fn slash_prefix_requires_command_start() { - assert_eq!(slash_prefix("/he", 3), Some("/he")); - assert_eq!(slash_prefix(" /he", 4), None); - assert_eq!(slash_prefix("/help now", 9), None); - } - - #[test] - fn slash_completion_matches_prefix() { - let helper = OpenMelonHelper::new(); - let history = DefaultHistory::new(); - let ctx = RustyContext::new(&history); - let (_start, candidates) = helper.complete("/mod", 4, &ctx).unwrap(); - - assert!(candidates - .iter() - .any(|candidate| candidate.replacement == "/model")); - assert!(candidates - .iter() - .any(|candidate| candidate.replacement == "/model-image")); - } - - #[test] - fn hint_shows_placeholder_and_slash_palette_seed() { - let helper = OpenMelonHelper::new(); - let history = DefaultHistory::new(); - let ctx = RustyContext::new(&history); - - let placeholder = helper.hint("", 0, &ctx).unwrap(); - assert_eq!(placeholder.display(), "Ask OpenMelon"); - assert!(placeholder.completion().is_none()); - - let slash = helper.hint("/", 1, &ctx).unwrap(); - assert!(slash.display().contains("/help")); - assert!(slash.display().contains("/status")); - assert!(slash.completion().is_none()); - } -} diff --git a/rust/crates/openmelon-tui/src/tools.rs b/rust/crates/openmelon-tui/src/tools.rs deleted file mode 100644 index 088c144..0000000 --- a/rust/crates/openmelon-tui/src/tools.rs +++ /dev/null @@ -1,1628 +0,0 @@ -use std::fs; -use std::io::{self, IsTerminal, Write}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::mpsc; -use std::time::Duration; - -use anyhow::{bail, Context, Result}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use time::macros::format_description; -use time::OffsetDateTime; - -use crate::image::ImageGenerator; -use crate::llm::Tool; -use crate::project::{resolve_output_dir, Workspace}; - -#[derive(Clone)] -pub struct ToolRegistry { - tools: Vec, -} - -pub struct ToolEnv { - pub workspace: Workspace, - pub session_id: String, - pub session_dir: PathBuf, - pub image: Option, - pub bash_mode: String, - pub allowed_bash: std::sync::Arc>>, - pub approve_bash: Option, -} - -pub type ApprovalFn = std::sync::Arc ApprovalDecision + Send + Sync>; - -#[derive(Debug, Clone)] -pub struct ApprovalRequest { - pub command: String, - pub description: String, - pub binary: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalDecision { - Yes, - Always, - No, -} - -#[derive(Clone)] -struct ToolEntry { - spec: Tool, - handler: fn(&ToolEnv, Value) -> Result, -} - -impl ToolRegistry { - pub fn standard(env: &ToolEnv) -> Self { - let mut registry = Self { tools: Vec::new() }; - registry.register(list_characters_tool()); - registry.register(get_character_tool()); - registry.register(list_references_tool()); - registry.register(get_reference_tool()); - registry.register(search_tool()); - registry.register(read_file_tool()); - registry.register(list_spaces_tool()); - registry.register(plan_creator_workflow_tool()); - registry.register(create_space_tool()); - registry.register(get_context_packet_tool()); - registry.register(activate_space_tool()); - registry.register(record_decision_tool()); - registry.register(record_feedback_tool()); - registry.register(record_memory_item_tool()); - registry.register(promote_memory_item_tool()); - registry.register(create_episode_tool()); - registry.register(register_asset_tool()); - registry.register(update_asset_weight_tool()); - registry.register(record_compaction_tool()); - registry.register(compile_skill_tool()); - registry.register(save_artifact_tool()); - registry.register(bash_tool()); - if env.image.is_some() { - registry.register(generate_image_tool()); - } - registry.register(finish_tool()); - registry - } - - pub fn specs(&self) -> Vec { - self.tools.iter().map(|entry| entry.spec.clone()).collect() - } - - pub fn names(&self) -> Vec { - self.tools - .iter() - .map(|entry| entry.spec.name.clone()) - .collect() - } - - pub fn dispatch(&self, env: &ToolEnv, name: &str, args: Value) -> Result { - let entry = self - .tools - .iter() - .find(|entry| entry.spec.name == name) - .with_context(|| format!("unknown tool {name}; available: {:?}", self.names()))?; - (entry.handler)(env, args) - } - - fn register(&mut self, entry: ToolEntry) { - self.tools.push(entry); - } -} - -fn list_characters_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "list_characters", - "List all characters registered in this project. Optional substring filter on name and description.", - schema(&[("query", "string", false)]), - ), - handler: |env, args| list_registry(env, "characters", "character.json", args), - } -} - -fn get_character_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "get_character", - "Fetch a character's full details, including absolute paths to portrait images.", - required_schema(&[("slug", "string")]), - ), - handler: |env, args| get_registry(env, "characters", "character.json", args), - } -} - -fn list_references_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "list_references", - "List all reference images in this project, such as scenes, lighting setups, or composition templates.", - schema(&[("query", "string", false)]), - ), - handler: |env, args| list_registry(env, "references", "reference.json", args), - } -} - -fn get_reference_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "get_reference", - "Fetch a reference image's full details, including absolute on-disk image paths.", - required_schema(&[("slug", "string")]), - ), - handler: |env, args| get_registry(env, "references", "reference.json", args), - } -} - -fn search_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "search", - "Search project characters, references, materials, spaces, and visible project text files.", - required_schema(&[("query", "string")]), - ), - handler: search_project, - } -} - -fn read_file_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "read_file", - "Read a UTF-8 text file from inside the project workdir. Paths may not escape the project.", - required_schema(&[("path", "string")]), - ), - handler: read_file, - } -} - -fn list_spaces_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "list_spaces", - "List or search creative continuity spaces before starting or continuing a durable series.", - schema(&[("query", "string", false)]), - ), - handler: list_spaces, - } -} - -fn get_context_packet_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "get_context_packet", - "Fetch a model-readable continuity context packet for a creative space.", - required_schema(&[("space_id", "string")]), - ), - handler: get_context_packet, - } -} - -fn plan_creator_workflow_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "plan_creator_workflow", - "Plan whether a creative request should start a new space, confirm a draft, or continue an active space.", - required_schema(&[("intent", "string")]), - ), - handler: plan_creator_workflow, - } -} - -fn create_space_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "create_space", - "Create a draft creative continuity space with provisional assumptions. Ask for confirmation before durable canon/episodes.", - serde_json::json!({ - "type": "object", - "properties": { - "id": {"type": "string"}, - "name": {"type": "string"}, - "platform": {"type": "string"}, - "audience": {"type": "string"}, - "description": {"type": "string"}, - "tags": {"type": "array", "items": {"type": "string"}}, - "assumptions": {"type": "string"} - }, - "required": ["id", "name"] - }), - ), - handler: create_space, - } -} - -fn activate_space_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "activate_space", - "Activate a draft creative space after explicit user confirmation and record the confirmation as a decision.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "decision": {"type": "string"}, - "reason": {"type": "string"}, - "weight": {"type": "number"} - }, - "required": ["space_id", "decision"] - }), - ), - handler: activate_space, - } -} - -fn record_decision_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "record_decision", - "Record a user-confirmed continuity decision for a creative space. Do not use for guesses.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "scope": {"type": "string"}, - "target": {"type": "string"}, - "decision": {"type": "string"}, - "reason": {"type": "string"}, - "weight": {"type": "number"} - }, - "required": ["space_id", "decision"] - }), - ), - handler: record_decision, - } -} - -fn record_feedback_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "record_feedback", - "Record user or audience feedback so future production can adapt strategy, pacing, style, assets, or planning.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "episode_id": {"type": "string"}, - "source": {"type": "string"}, - "signal": {"type": "string"}, - "evidence": {"type": "string"}, - "recommendation": {"type": "string"} - }, - "required": ["space_id", "signal"] - }), - ), - handler: record_feedback, - } -} - -fn record_memory_item_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "record_memory_item", - "Record a provisional memory item for observations, patterns, weak preferences, risks, or unresolved continuity notes.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "id": {"type": "string"}, - "kind": {"type": "string"}, - "scope": {"type": "string"}, - "target": {"type": "string"}, - "content": {"type": "string"}, - "source": {"type": "string"}, - "weight": {"type": "number"}, - "status": {"type": "string"} - }, - "required": ["space_id", "content"] - }), - ), - handler: record_memory_item, - } -} - -fn promote_memory_item_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "promote_memory_item", - "Promote a provisional memory item into a user-confirmed decision after explicit confirmation.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "item_id": {"type": "string"}, - "decision": {"type": "string"}, - "reason": {"type": "string"}, - "target": {"type": "string"} - }, - "required": ["space_id", "item_id", "decision"] - }), - ), - handler: promote_memory_item, - } -} - -fn create_episode_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "create_episode", - "Create or register a durable episode under an active creative space.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "id": {"type": "string"}, - "title": {"type": "string"}, - "topic": {"type": "string"}, - "status": {"type": "string"}, - "brief": {"type": "string"} - }, - "required": ["space_id", "topic"] - }), - ), - handler: create_episode, - } -} - -fn register_asset_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "register_asset", - "Register a reusable continuity asset: image, background, character, prop, typography rule, prompt fragment, shot spec, mask, or layered file.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "id": {"type": "string"}, - "kind": {"type": "string"}, - "status": {"type": "string"}, - "description": {"type": "string"}, - "reuse_policy": {"type": "string"}, - "files": {"type": "array", "items": {"type": "string"}}, - "tags": {"type": "array", "items": {"type": "string"}}, - "weight": {"type": "number"} - }, - "required": ["space_id", "description"] - }), - ), - handler: register_asset, - } -} - -fn update_asset_weight_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "update_asset_weight", - "Adjust a reusable asset's weight or status after feedback.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "asset_id": {"type": "string"}, - "weight": {"type": "number"}, - "status": {"type": "string"} - }, - "required": ["space_id", "asset_id", "weight"] - }), - ), - handler: update_asset_weight, - } -} - -fn record_compaction_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "record_compaction", - "Record a compact summary of long-running state for a creative space.", - serde_json::json!({ - "type": "object", - "properties": { - "space_id": {"type": "string"}, - "summary": {"type": "string"}, - "scope": {"type": "string"} - }, - "required": ["space_id", "summary"] - }), - ), - handler: record_compaction, - } -} - -fn compile_skill_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "compile_skill", - "Compile a skillplus package and return its compiled prompt + output schema. Pass the BARE skill slug, not skillplus:.", - serde_json::json!({ - "type": "object", - "properties": { - "skill": { - "type": "string", - "description": "Bare skill slug, such as brand-logo, or an absolute path to a .skillplus directory. Do not prefix with skillplus:." - }, - "locale": { - "type": "string", - "description": "Locale to compile for. Allowed: zh-CN or en. Default zh-CN.", - "enum": ["zh-CN", "en"] - }, - "model_profile": { - "type": "string", - "description": "Per-skill prompt overlay slug. Default gpt-image-family." - }, - "vars": {"type": "object", "additionalProperties": {"type": "string"}} - }, - "required": ["skill"] - }), - ), - handler: compile_skill, - } -} - -fn generate_image_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "generate_image", - "Generate a single image and save it into the visible project outputs directory for the current session. Include continuity constraints in the prompt and never write final deliverables under .openmelon.", - serde_json::json!({ - "type": "object", - "properties": { - "prompt": {"type": "string"}, - "reference_images": {"type": "array", "items": {"type": "string"}}, - "size": {"type": "string"}, - "label": {"type": "string"}, - "output_dir": {"type": "string"} - }, - "required": ["prompt"] - }), - ), - handler: generate_image, - } -} - -fn save_artifact_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "save_artifact", - "Promote a generated file to a permanent visible project artifact under outputs/artifacts///, or a project-relative output_dir.", - serde_json::json!({ - "type": "object", - "properties": { - "slug": {"type": "string"}, - "image_path": {"type": "string"}, - "prompt": {"type": "string"}, - "output_dir": {"type": "string"} - }, - "required": ["slug", "image_path"] - }), - ), - handler: save_artifact, - } -} - -fn bash_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "bash", - "Run a shell command inside the project workdir. Use for lightweight inspection only. Do not use bash to discover fonts, render images, or replace image generation.", - serde_json::json!({ - "type": "object", - "properties": { - "command": {"type": "string"}, - "description": {"type": "string"}, - "timeout_seconds": {"type": "number"} - }, - "required": ["command", "description"] - }), - ), - handler: bash, - } -} - -fn finish_tool() -> ToolEntry { - ToolEntry { - spec: tool( - "finish", - "Signal completion. Provide a short user-visible summary and any final artifact paths.", - serde_json::json!({ - "type": "object", - "properties": { - "summary": {"type": "string"}, - "artifacts": {"type": "array", "items": {"type": "string"}} - }, - "required": ["summary"] - }), - ), - handler: |_env, args| { - Ok(serde_json::json!({ - "ok": true, - "summary": string_arg(&args, "summary"), - "artifacts": args.get("artifacts").cloned().unwrap_or_else(|| serde_json::json!([])), - })) - }, - } -} - -fn tool(name: &str, description: &str, parameters: Value) -> Tool { - Tool { - name: name.to_string(), - description: description.to_string(), - parameters, - } -} - -fn schema(fields: &[(&str, &str, bool)]) -> Value { - let mut props = serde_json::Map::new(); - let mut required = Vec::new(); - for (name, kind, req) in fields { - props.insert((*name).to_string(), serde_json::json!({ "type": kind })); - if *req { - required.push(Value::String((*name).to_string())); - } - } - serde_json::json!({ - "type": "object", - "properties": Value::Object(props), - "required": required, - }) -} - -fn required_schema(fields: &[(&str, &str)]) -> Value { - let fields = fields - .iter() - .map(|(a, b)| (*a, *b, true)) - .collect::>(); - schema(&fields) -} - -fn list_registry(env: &ToolEnv, dir: &str, meta_name: &str, args: Value) -> Result { - let query = string_arg(&args, "query").to_ascii_lowercase(); - let root = env.workspace.state_dir().join(dir); - let mut out = Vec::new(); - if !root.exists() { - return Ok(Value::Array(out)); - } - for entry in fs::read_dir(root)? { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - let item = load_registry_item(&entry.path(), meta_name)?; - let hay = format!( - "{} {} {}", - item.get("slug").and_then(Value::as_str).unwrap_or_default(), - item.get("name").and_then(Value::as_str).unwrap_or_default(), - item.get("description") - .and_then(Value::as_str) - .unwrap_or_default() - ) - .to_ascii_lowercase(); - if query.is_empty() || hay.contains(&query) { - out.push(item); - } - } - Ok(Value::Array(out)) -} - -fn get_registry(env: &ToolEnv, dir: &str, meta_name: &str, args: Value) -> Result { - let slug = string_arg(&args, "slug"); - if slug.trim().is_empty() { - return Ok(error_value("slug is required")); - } - let path = env.workspace.state_dir().join(dir).join(&slug); - if !path.exists() { - return Ok(error_value(&format!("{dir}/{slug} not found"))); - } - load_registry_item(&path, meta_name) -} - -fn load_registry_item(path: &Path, meta_name: &str) -> Result { - let meta_path = path.join(meta_name); - let mut item = if meta_path.exists() { - serde_json::from_str::(&fs::read_to_string(&meta_path)?)? - } else { - serde_json::json!({}) - }; - if item.get("slug").is_none() { - item["slug"] = Value::String( - path.file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(), - ); - } - if let Ok(search) = fs::read_to_string(path.join(".search")) { - item["description"] = Value::String(search.trim().to_string()); - } - let mut images = Vec::new(); - for entry in fs::read_dir(path)? { - let entry = entry?; - if !entry.file_type()?.is_file() { - continue; - } - let p = entry.path(); - if is_image_path(&p) { - images.push(Value::String(p.display().to_string())); - } - } - item["images"] = Value::Array(images); - Ok(item) -} - -fn search_project(env: &ToolEnv, args: Value) -> Result { - let query = string_arg(&args, "query").to_ascii_lowercase(); - if query.trim().is_empty() { - return Ok(error_value("query is required")); - } - let mut hits = Vec::new(); - collect_text_hits( - &env.workspace.state_dir().join("characters"), - &query, - &mut hits, - )?; - collect_text_hits( - &env.workspace.state_dir().join("references"), - &query, - &mut hits, - )?; - collect_text_hits( - &env.workspace.state_dir().join("materials"), - &query, - &mut hits, - )?; - collect_text_hits(&env.workspace.state_dir().join("spaces"), &query, &mut hits)?; - collect_text_hits(&env.workspace.outputs_dir(), &query, &mut hits)?; - hits.truncate(40); - Ok(Value::Array(hits)) -} - -fn collect_text_hits(root: &Path, query: &str, hits: &mut Vec) -> Result<()> { - if !root.exists() { - return Ok(()); - } - for entry in fs::read_dir(root)? { - let entry = entry?; - let path = entry.path(); - if entry.file_type()?.is_dir() { - collect_text_hits(&path, query, hits)?; - continue; - } - if !is_text_path(&path) { - continue; - } - let Ok(body) = fs::read_to_string(&path) else { - continue; - }; - if body.to_ascii_lowercase().contains(query) { - hits.push(serde_json::json!({ - "path": path.display().to_string(), - "snippet": snippet(&body, query), - })); - } - } - Ok(()) -} - -fn read_file(env: &ToolEnv, args: Value) -> Result { - let rel = string_arg(&args, "path"); - let path = safe_join(&env.workspace.root, &rel)?; - let content = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - Ok(serde_json::json!({ "path": rel, "content": content })) -} - -fn list_spaces(env: &ToolEnv, args: Value) -> Result { - let query = string_arg(&args, "query").to_ascii_lowercase(); - let root = env.workspace.state_dir().join("spaces"); - let mut out = Vec::new(); - if !root.exists() { - return Ok(Value::Array(out)); - } - for entry in fs::read_dir(root)? { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - let path = entry.path().join("space.json"); - if !path.exists() { - continue; - } - let space: Value = serde_json::from_str(&fs::read_to_string(path)?)?; - let hay = serde_json::to_string(&space)?.to_ascii_lowercase(); - if query.is_empty() || hay.contains(&query) { - out.push(space); - } - } - Ok(Value::Array(out)) -} - -fn get_context_packet(env: &ToolEnv, args: Value) -> Result { - let space_id = string_arg(&args, "space_id"); - if space_id.trim().is_empty() { - return Ok(error_value("space_id is required")); - } - let dir = env.workspace.state_dir().join("spaces").join(&space_id); - if !dir.exists() { - return Ok(error_value(&format!("space {space_id} not found"))); - } - let read = |name: &str| fs::read_to_string(dir.join(name)).unwrap_or_default(); - let jsonl = |name: &str, limit: usize| read_jsonl_tail(&dir.join(name), limit); - Ok(serde_json::json!({ - "project_id": env.workspace.project.id, - "authority": "canon and decisions outrank memory and assumptions; assumptions are provisional until user confirmation", - "space": read_json_file(&dir.join("space.json")).unwrap_or_else(|| serde_json::json!({ "id": space_id })), - "assumptions": read("assumptions.md"), - "canon": read("canon.md"), - "memory": read("memory.md"), - "plan": read("plan.md"), - "recent_decisions": jsonl("decisions.jsonl", 12), - "recent_feedback": jsonl("feedback.jsonl", 12), - "recent_episodes": collect_json_files(&dir.join("episodes"), 12), - "assets": collect_json_files(&dir.join("assets"), 24), - })) -} - -fn plan_creator_workflow(env: &ToolEnv, args: Value) -> Result { - let intent = string_arg(&args, "intent").to_ascii_lowercase(); - let spaces = list_spaces(env, serde_json::json!({ "query": intent }))?; - let best = spaces.as_array().and_then(|items| items.first()).cloned(); - let Some(space) = best else { - return Ok(serde_json::json!({ - "intent": string_arg(&args, "intent"), - "mode": "new_space", - "needs_confirmation": true, - "reason": "No matching creative space was found; start with provisional assumptions and ask for confirmation.", - "steps": [ - {"id": "find-context", "action": "search existing spaces", "tool": "list_spaces"}, - {"id": "draft-space", "action": "create draft space", "tool": "create_space"}, - {"id": "ask-confirmation", "action": "ask concise confirmation questions"} - ] - })); - }; - let status = space - .get("status") - .and_then(Value::as_str) - .unwrap_or_default(); - let space_id = space.get("id").and_then(Value::as_str).unwrap_or_default(); - if status == "draft" { - Ok(serde_json::json!({ - "intent": string_arg(&args, "intent"), - "mode": "confirm_space", - "space_id": space_id, - "needs_confirmation": true, - "reason": "A draft space matches; confirm or correct assumptions before durable production.", - "steps": [ - {"id": "load-context", "action": "load selected context", "tool": "get_context_packet"}, - {"id": "ask-confirmation", "action": "ask user to confirm or correct core direction"}, - {"id": "activate", "action": "activate after confirmation", "tool": "activate_space"} - ] - })) - } else { - Ok(serde_json::json!({ - "intent": string_arg(&args, "intent"), - "mode": "continue_space", - "space_id": space_id, - "needs_confirmation": false, - "reason": "An active creative space matches; load context and continue production.", - "steps": [ - {"id": "load-context", "action": "load selected context", "tool": "get_context_packet"}, - {"id": "adapt", "action": "adapt using feedback and memory"}, - {"id": "produce", "action": "create or update episode/assets", "tool": "create_episode"}, - {"id": "finish", "action": "summarize updates", "tool": "finish"} - ] - })) - } -} - -fn create_space(env: &ToolEnv, args: Value) -> Result { - let id = clean_label(&string_arg(&args, "id")); - if id.is_empty() { - return Ok(error_value("id is required")); - } - let dir = env.workspace.state_dir().join("spaces").join(&id); - if dir.join("space.json").exists() { - return Ok(error_value(&format!("space {id} already exists"))); - } - fs::create_dir_all(dir.join("episodes"))?; - fs::create_dir_all(dir.join("assets"))?; - let now = now_rfc3339()?; - let space = serde_json::json!({ - "id": id, - "name": string_arg(&args, "name"), - "platform": string_arg(&args, "platform"), - "audience": string_arg(&args, "audience"), - "status": "draft", - "description": string_arg(&args, "description"), - "tags": array_arg(&args, "tags"), - "created_at": now, - "updated_at": now, - }); - write_json(&dir.join("space.json"), &space)?; - fs::write( - dir.join("assumptions.md"), - ensure_newline(&first_non_empty_str(&[ - string_arg(&args, "assumptions"), - "# Assumptions\n\nModel-generated setup assumptions live here until confirmed." - .to_string(), - ])), - )?; - fs::write( - dir.join("canon.md"), - "# Canon\n\nConfirmed long-term rules live here.\n", - )?; - fs::write(dir.join("memory.md"), "# Memory\n\n")?; - fs::write(dir.join("plan.md"), "# Plan\n\n## Backlog\n- TBD\n")?; - Ok(serde_json::json!({ - "id": id, - "status": "draft", - "dir": dir.display().to_string(), - "next_action": "Ask the user to confirm or correct assumptions before durable canon or episodes.", - })) -} - -fn activate_space(env: &ToolEnv, args: Value) -> Result { - let space_id = string_arg(&args, "space_id"); - let dir = env.workspace.state_dir().join("spaces").join(&space_id); - let Some(mut space) = read_json_file(&dir.join("space.json")) else { - return Ok(error_value(&format!("space {space_id} not found"))); - }; - let decision = append_decision(env, &args, "space", "space_activation")?; - space["status"] = serde_json::json!("active"); - space["updated_at"] = serde_json::json!(now_rfc3339()?); - write_json(&dir.join("space.json"), &space)?; - Ok(serde_json::json!({ "space": space, "decision": decision })) -} - -fn record_decision(env: &ToolEnv, args: Value) -> Result { - append_decision( - env, - &args, - &string_arg(&args, "scope"), - &string_arg(&args, "target"), - ) -} - -fn append_decision(env: &ToolEnv, args: &Value, scope: &str, target: &str) -> Result { - let space_id = string_arg(args, "space_id"); - let decision = string_arg(args, "decision"); - if space_id.is_empty() || decision.is_empty() { - return Ok(error_value("space_id and decision are required")); - } - let dir = env.workspace.state_dir().join("spaces").join(&space_id); - if !dir.exists() { - return Ok(error_value(&format!("space {space_id} not found"))); - } - let now = now_rfc3339()?; - let row = serde_json::json!({ - "id": format!("dec-{}", compact_timestamp()?), - "scope": first_non_empty_str(&[scope.to_string(), "space".to_string()]), - "target": target, - "decision": decision, - "reason": string_arg(args, "reason"), - "weight": args.get("weight").and_then(Value::as_f64).unwrap_or(1.0), - "status": "active", - "created_at": now, - }); - append_jsonl(&dir.join("decisions.jsonl"), &row)?; - Ok(row) -} - -fn record_feedback(env: &ToolEnv, args: Value) -> Result { - append_space_jsonl(env, &args, "feedback.jsonl", |args| { - serde_json::json!({ - "id": format!("fb-{}", compact_timestamp().unwrap_or_else(|_| "now".to_string())), - "episode_id": string_arg(args, "episode_id"), - "source": first_non_empty_str(&[string_arg(args, "source"), "user".to_string()]), - "signal": string_arg(args, "signal"), - "evidence": string_arg(args, "evidence"), - "recommendation": string_arg(args, "recommendation"), - "created_at": now_rfc3339().unwrap_or_default(), - }) - }) -} - -fn record_memory_item(env: &ToolEnv, args: Value) -> Result { - append_space_jsonl(env, &args, "memory.jsonl", |args| { - serde_json::json!({ - "id": first_non_empty_str(&[clean_label(&string_arg(args, "id")), format!("mem-{}", compact_timestamp().unwrap_or_else(|_| "now".to_string()))]), - "kind": first_non_empty_str(&[string_arg(args, "kind"), "observation".to_string()]), - "scope": string_arg(args, "scope"), - "target": string_arg(args, "target"), - "content": string_arg(args, "content"), - "source": first_non_empty_str(&[string_arg(args, "source"), "model".to_string()]), - "weight": args.get("weight").and_then(Value::as_f64).unwrap_or(0.5), - "status": first_non_empty_str(&[string_arg(args, "status"), "provisional".to_string()]), - "created_at": now_rfc3339().unwrap_or_default(), - "updated_at": now_rfc3339().unwrap_or_default(), - }) - }) -} - -fn promote_memory_item(env: &ToolEnv, args: Value) -> Result { - let mut decision_args = args.clone(); - decision_args["scope"] = serde_json::json!("memory"); - decision_args["target"] = serde_json::json!(string_arg(&args, "item_id")); - append_decision(env, &decision_args, "memory", &string_arg(&args, "item_id")) -} - -fn create_episode(env: &ToolEnv, args: Value) -> Result { - let space_id = string_arg(&args, "space_id"); - let dir = env.workspace.state_dir().join("spaces").join(&space_id); - let Some(space) = read_json_file(&dir.join("space.json")) else { - return Ok(error_value(&format!("space {space_id} not found"))); - }; - if space.get("status").and_then(Value::as_str) == Some("draft") { - return Ok(error_value( - "space is draft; activate it after user confirmation before creating durable episodes", - )); - } - let id = first_non_empty_str(&[ - clean_label(&string_arg(&args, "id")), - clean_label(&string_arg(&args, "topic")), - "episode".to_string(), - ]); - let ep_dir = dir.join("episodes").join(&id); - fs::create_dir_all(&ep_dir)?; - let now = now_rfc3339()?; - let episode = serde_json::json!({ - "id": id, - "title": string_arg(&args, "title"), - "topic": string_arg(&args, "topic"), - "status": first_non_empty_str(&[string_arg(&args, "status"), "draft".to_string()]), - "brief": string_arg(&args, "brief"), - "created_at": now, - "updated_at": now, - }); - write_json(&ep_dir.join("episode.json"), &episode)?; - if !string_arg(&args, "brief").is_empty() { - fs::write( - ep_dir.join("brief.md"), - ensure_newline(&string_arg(&args, "brief")), - )?; - } - Ok(episode) -} - -fn register_asset(env: &ToolEnv, args: Value) -> Result { - let space_id = string_arg(&args, "space_id"); - let dir = env.workspace.state_dir().join("spaces").join(&space_id); - if !dir.exists() { - return Ok(error_value(&format!("space {space_id} not found"))); - } - let id = first_non_empty_str(&[ - clean_label(&string_arg(&args, "id")), - clean_label(&string_arg(&args, "description")), - "asset".to_string(), - ]); - let asset_dir = dir.join("assets").join(&id); - fs::create_dir_all(&asset_dir)?; - let now = now_rfc3339()?; - let asset = serde_json::json!({ - "id": id, - "kind": string_arg(&args, "kind"), - "space_id": space_id, - "status": first_non_empty_str(&[string_arg(&args, "status"), "active".to_string()]), - "description": string_arg(&args, "description"), - "reuse_policy": string_arg(&args, "reuse_policy"), - "files": array_arg(&args, "files"), - "tags": array_arg(&args, "tags"), - "weight": args.get("weight").and_then(Value::as_f64).unwrap_or(1.0), - "created_at": now, - "updated_at": now, - }); - write_json(&asset_dir.join("asset.json"), &asset)?; - Ok(asset) -} - -fn update_asset_weight(env: &ToolEnv, args: Value) -> Result { - let space_id = string_arg(&args, "space_id"); - let asset_id = string_arg(&args, "asset_id"); - let path = env - .workspace - .state_dir() - .join("spaces") - .join(&space_id) - .join("assets") - .join(&asset_id) - .join("asset.json"); - let Some(mut asset) = read_json_file(&path) else { - return Ok(error_value(&format!("asset {asset_id} not found"))); - }; - asset["weight"] = serde_json::json!(args.get("weight").and_then(Value::as_f64).unwrap_or(1.0)); - let status = string_arg(&args, "status"); - if !status.is_empty() { - asset["status"] = serde_json::json!(status); - } - asset["updated_at"] = serde_json::json!(now_rfc3339()?); - write_json(&path, &asset)?; - Ok(asset) -} - -fn record_compaction(env: &ToolEnv, args: Value) -> Result { - append_space_jsonl(env, &args, "compactions.jsonl", |args| { - serde_json::json!({ - "id": format!("cmp-{}", compact_timestamp().unwrap_or_else(|_| "now".to_string())), - "summary": string_arg(args, "summary"), - "scope": first_non_empty_str(&[string_arg(args, "scope"), "space".to_string()]), - "created_at": now_rfc3339().unwrap_or_default(), - }) - }) -} - -fn append_space_jsonl( - env: &ToolEnv, - args: &Value, - file_name: &str, - make_row: impl Fn(&Value) -> Value, -) -> Result { - let space_id = string_arg(args, "space_id"); - if space_id.is_empty() { - return Ok(error_value("space_id is required")); - } - let dir = env.workspace.state_dir().join("spaces").join(&space_id); - if !dir.exists() { - return Ok(error_value(&format!("space {space_id} not found"))); - } - let row = make_row(args); - append_jsonl(&dir.join(file_name), &row)?; - Ok(row) -} - -fn compile_skill(_env: &ToolEnv, args: Value) -> Result { - let skill = normalize_skill_spec(&string_arg(&args, "skill")); - if skill.is_empty() { - return Ok(error_value("skill is required")); - } - let locale = normalize_locale(&string_arg(&args, "locale")); - let model_profile = first_non_empty_str(&[ - string_arg(&args, "model_profile"), - "gpt-image-family".to_string(), - ]); - - let mut cli_args = vec![ - skill.clone(), - "--target".to_string(), - "openmelon".to_string(), - "--model-profile".to_string(), - model_profile.clone(), - ]; - if !locale.is_empty() { - cli_args.push("--locale".to_string()); - cli_args.push(locale.clone()); - } - if let Some(vars) = args.get("vars").and_then(Value::as_object) { - for (key, value) in vars { - let rendered = value - .as_str() - .map(ToString::to_string) - .unwrap_or_else(|| value.to_string()); - cli_args.push("--var".to_string()); - cli_args.push(format!("{key}={rendered}")); - } - } - - match Command::new("skillplus").args(&cli_args).output() { - Ok(output) => return parse_skillplus_output(&skill, "skillplus", output), - Err(skillplus_err) if skillplus_err.kind() != io::ErrorKind::NotFound => { - return Ok(error_value(&format!( - "skillplus compile failed for {skill:?}: {skillplus_err}" - ))); - } - Err(_) => {} - } - - let python = std::env::var("OPENMELON_SKILLPLUS_PYTHON").unwrap_or_else(|_| "python3".into()); - let mut py_args = vec!["-m".to_string(), "skillplus".to_string()]; - py_args.extend(cli_args); - let mut cmd = Command::new(&python); - cmd.args(&py_args); - if let Ok(path) = std::env::var("OPENMELON_SKILLPLUS_PYTHONPATH") { - if !path.trim().is_empty() { - cmd.env("PYTHONPATH", path); - } - } - match cmd.output() { - Ok(output) => parse_skillplus_output(&skill, &format!("{python} -m skillplus"), output), - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(error_value(&format!( - "skillplus: neither \"skillplus\" nor \"{python}\" is on PATH; install skillplus or set OPENMELON_SKILLPLUS_PYTHON/OPENMELON_SKILLPLUS_PYTHONPATH" - ))), - Err(err) => Ok(error_value(&format!( - "skillplus compile failed for {skill:?}: {err}" - ))), - } -} - -fn generate_image(env: &ToolEnv, args: Value) -> Result { - let Some(generator) = &env.image else { - return Ok(error_value("image generation is not configured")); - }; - let prompt = string_arg(&args, "prompt"); - if prompt.trim().is_empty() { - return Ok(error_value("prompt is required")); - } - let reference_images = args - .get("reference_images") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .into_iter() - .filter_map(|v| v.as_str().map(ToString::to_string)) - .collect::>(); - let result = generator.generate(&prompt, &string_arg(&args, "size"), &reference_images)?; - let label = clean_label(&string_arg(&args, "label")); - let fallback = if env.session_dir.exists() { - env.workspace.session_outputs_dir(&env.session_id) - } else { - env.workspace.outputs_dir() - }; - let out_dir = resolve_output_dir( - &env.workspace.root, - &string_arg(&args, "output_dir"), - &fallback, - )?; - fs::create_dir_all(&out_dir)?; - let name = format!( - "{}-{}{}", - if label.is_empty() { "image" } else { &label }, - OffsetDateTime::now_utc().format(format_description!("[hour][minute][second]"))?, - result.extension() - ); - let out_path = out_dir.join(name); - fs::write(&out_path, &result.data)?; - Ok(serde_json::json!({ - "path": out_path.display().to_string(), - "label": label, - "sha256": sha256_hex(&result.data), - "size_bytes": result.data.len(), - "prompt": prompt, - })) -} - -fn save_artifact(env: &ToolEnv, args: Value) -> Result { - let slug = clean_label(&string_arg(&args, "slug")); - let image_path = string_arg(&args, "image_path"); - if slug.is_empty() { - return Ok(error_value("slug is required")); - } - if image_path.trim().is_empty() { - return Ok(error_value("image_path is required")); - } - let source = safe_join_allow_abs(&env.workspace.root, &image_path)?; - let data = fs::read(&source).with_context(|| format!("read {}", source.display()))?; - let ts = OffsetDateTime::now_utc().format(format_description!( - "[year][month][day]-[hour][minute][second]" - ))?; - let fallback = env.workspace.artifact_outputs_dir(&slug, &ts); - let out_dir = resolve_output_dir( - &env.workspace.root, - &string_arg(&args, "output_dir"), - &fallback, - )?; - fs::create_dir_all(&out_dir)?; - let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("png"); - let out_path = out_dir.join(format!("image.{ext}")); - fs::write(&out_path, &data)?; - let prompt = string_arg(&args, "prompt"); - if !prompt.trim().is_empty() { - fs::write(out_dir.join("prompt.txt"), prompt)?; - } - Ok(serde_json::json!({ - "path": out_path.display().to_string(), - "sha256": sha256_hex(&data), - })) -} - -fn bash(env: &ToolEnv, args: Value) -> Result { - let command = string_arg(&args, "command"); - if command.trim().is_empty() { - return Ok(error_value("command is required")); - } - let description = string_arg(&args, "description"); - let binary = first_binary(&command); - let approved_via = approve_bash(env, &command, &description, &binary)?; - if let Some(error) = approved_via.strip_prefix("error:") { - return Ok(error_value(error.trim())); - } - let timeout = args - .get("timeout_seconds") - .and_then(Value::as_f64) - .unwrap_or(30.0) - .clamp(1.0, 300.0); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(&command) - .current_dir(&env.workspace.root) - .env("OPENMELON_BASH_TIMEOUT_SECONDS", timeout.to_string()) - .output() - .with_context(|| format!("run bash command: {command}"))?; - Ok(serde_json::json!({ - "stdout": String::from_utf8_lossy(&output.stdout).to_string() + &String::from_utf8_lossy(&output.stderr), - "exit_code": output.status.code().unwrap_or(-1), - "approved_via": approved_via, - "timeout_seconds": Duration::from_secs_f64(timeout).as_secs(), - })) -} - -fn string_arg(args: &Value, name: &str) -> String { - args.get(name) - .and_then(Value::as_str) - .unwrap_or_default() - .trim() - .to_string() -} - -fn error_value(message: &str) -> Value { - serde_json::json!({ "error": message }) -} - -fn normalize_skill_spec(value: &str) -> String { - value - .trim() - .strip_prefix("skillplus:") - .unwrap_or(value.trim()) - .strip_prefix("path:") - .unwrap_or_else(|| { - value - .trim() - .strip_prefix("skillplus:") - .unwrap_or(value.trim()) - }) - .trim() - .to_string() -} - -fn normalize_locale(value: &str) -> String { - match value.trim().to_ascii_lowercase().as_str() { - "" | "zh" | "zh-cn" | "zh_cn" | "chinese" | "cn" => "zh-CN".to_string(), - "en" | "en-us" | "english" | "us" => "en".to_string(), - _ => value.trim().to_string(), - } -} - -fn parse_skillplus_output(skill: &str, via: &str, output: std::process::Output) -> Result { - if !output.status.success() { - let detail = first_non_empty_str(&[ - String::from_utf8_lossy(&output.stderr).trim().to_string(), - String::from_utf8_lossy(&output.stdout).trim().to_string(), - format!("exit {}", output.status.code().unwrap_or(-1)), - ]); - return Ok(error_value(&format!( - "skillplus compile failed for {skill:?} via {via}: {detail}" - ))); - } - let value: Value = match serde_json::from_slice(&output.stdout) { - Ok(value) => value, - Err(err) => { - return Ok(error_value(&format!( - "skillplus compiler output is not valid JSON: {err}" - ))) - } - }; - Ok(value) -} - -fn approve_bash(env: &ToolEnv, command: &str, description: &str, binary: &str) -> Result { - if env.bash_mode == "trusted" { - return Ok("trusted".to_string()); - } - if !binary.is_empty() - && env - .allowed_bash - .lock() - .map(|allowed| allowed.contains(binary)) - .unwrap_or(false) - { - return Ok("allowlisted".to_string()); - } - if env.bash_mode == "auto" && is_read_only_command(command) { - return Ok("read-only".to_string()); - } - if !io::stdin().is_terminal() { - return Ok( - "error:bash is unavailable: command needs approval but stdin is not interactive" - .to_string(), - ); - } - - if let Some(approve) = &env.approve_bash { - match approve(ApprovalRequest { - command: command.to_string(), - description: description.to_string(), - binary: binary.to_string(), - }) { - ApprovalDecision::Yes => return Ok("user-approved".to_string()), - ApprovalDecision::Always => { - if !binary.is_empty() { - let mut allowed = env - .allowed_bash - .lock() - .map_err(|_| anyhow::anyhow!("bash allowlist lock poisoned"))?; - allowed.insert(binary.to_string()); - } - return Ok("user-approved".to_string()); - } - ApprovalDecision::No => return Ok("error:user denied execution".to_string()), - } - } - - render_approval_request(command, description, binary)?; - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - match answer.trim().to_ascii_lowercase().as_str() { - "y" | "yes" => Ok("user-approved".to_string()), - "a" | "always" => { - if !binary.is_empty() { - let mut allowed = env - .allowed_bash - .lock() - .map_err(|_| anyhow::anyhow!("bash allowlist lock poisoned"))?; - allowed.insert(binary.to_string()); - } - Ok("user-approved".to_string()) - } - _ => Ok("error:user denied execution".to_string()), - } -} - -fn render_approval_request(command: &str, description: &str, binary: &str) -> Result<()> { - eprintln!(); - eprintln!("Do you want to proceed?"); - if !description.is_empty() { - eprintln!(" Reason: {description}"); - } - eprintln!(" Command: {command}"); - if binary.is_empty() { - eprint!("Approve? [y]es / [N]o: "); - } else { - eprint!("Approve? [y]es / [a]lways allow {binary} this session / [N]o: "); - } - io::stderr().flush()?; - Ok(()) -} - -pub fn channel_approval_fn( - tx: mpsc::Sender<(ApprovalRequest, mpsc::Sender)>, -) -> ApprovalFn { - std::sync::Arc::new(move |request| { - let (reply_tx, reply_rx) = mpsc::channel(); - if tx.send((request, reply_tx)).is_err() { - return ApprovalDecision::No; - } - reply_rx.recv().unwrap_or(ApprovalDecision::No) - }) -} - -fn safe_join(root: &Path, rel: &str) -> Result { - let path = root.join(rel.trim()); - let root = root.canonicalize()?; - let abs = path.canonicalize()?; - if !abs.starts_with(root) { - bail!("path escapes project workdir: {rel}"); - } - Ok(abs) -} - -fn safe_join_allow_abs(root: &Path, value: &str) -> Result { - let path = PathBuf::from(value.trim()); - let candidate = if path.is_absolute() { - path - } else { - root.join(path) - }; - let root = root.canonicalize()?; - let abs = candidate.canonicalize()?; - if !abs.starts_with(root) { - bail!("path escapes project workdir: {value}"); - } - Ok(abs) -} - -fn is_text_path(path: &Path) -> bool { - matches!( - path.extension().and_then(|e| e.to_str()).unwrap_or(""), - "json" | "jsonl" | "md" | "txt" | "search" - ) || path.file_name().and_then(|n| n.to_str()) == Some(".search") -} - -fn is_image_path(path: &Path) -> bool { - matches!( - path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase() - .as_str(), - "png" | "jpg" | "jpeg" | "webp" | "gif" - ) -} - -fn snippet(body: &str, query: &str) -> String { - let lower = body.to_ascii_lowercase(); - let idx = lower.find(query).unwrap_or(0); - let start = idx.saturating_sub(80); - let end = (idx + query.len() + 160).min(body.len()); - body[start..end].replace('\n', " ") -} - -fn read_json_file(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|body| serde_json::from_str(&body).ok()) -} - -fn write_json(path: &Path, value: &Value) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, serde_json::to_string_pretty(value)? + "\n")?; - Ok(()) -} - -fn append_jsonl(path: &Path, value: &Value) -> Result<()> { - use std::io::Write; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - serde_json::to_writer(&mut file, value)?; - file.write_all(b"\n")?; - Ok(()) -} - -fn read_jsonl_tail(path: &Path, limit: usize) -> Vec { - let Ok(body) = fs::read_to_string(path) else { - return Vec::new(); - }; - let mut rows = body - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .collect::>(); - if rows.len() > limit { - rows = rows.split_off(rows.len() - limit); - } - rows -} - -fn collect_json_files(root: &Path, limit: usize) -> Vec { - let Ok(entries) = fs::read_dir(root) else { - return Vec::new(); - }; - let mut rows = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - rows.extend(collect_json_files(&path, limit)); - } else if path.extension().and_then(|e| e.to_str()) == Some("json") { - if let Some(value) = read_json_file(&path) { - rows.push(value); - } - } - } - rows.truncate(limit); - rows -} - -fn clean_label(value: &str) -> String { - value - .trim() - .to_ascii_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .split('-') - .filter(|part| !part.is_empty()) - .collect::>() - .join("-") -} - -fn array_arg(args: &Value, name: &str) -> Value { - args.get(name) - .and_then(Value::as_array) - .cloned() - .map(Value::Array) - .unwrap_or_else(|| Value::Array(Vec::new())) -} - -fn now_rfc3339() -> Result { - Ok(OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?) -} - -fn compact_timestamp() -> Result { - Ok(OffsetDateTime::now_utc().format(format_description!( - "[year][month][day]-[hour][minute][second]" - ))?) -} - -fn ensure_newline(value: &str) -> String { - if value.ends_with('\n') { - value.to_string() - } else { - format!("{value}\n") - } -} - -fn first_non_empty_str(values: &[String]) -> String { - values - .iter() - .map(|value| value.trim()) - .find(|value| !value.is_empty()) - .unwrap_or_default() - .to_string() -} - -fn first_binary(command: &str) -> String { - for mut token in command.split_whitespace() { - if token.is_empty() { - continue; - } - if token.contains('=') && !token.contains('/') && !token.contains('\\') { - continue; - } - if matches!(token, "sudo" | "time" | "exec" | "nohup" | "env") { - continue; - } - if let Some(idx) = token.rfind(['/', '\\']) { - token = &token[idx + 1..]; - } - return token.to_string(); - } - String::new() -} - -fn sha256_hex(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - format!("{:x}", hasher.finalize()) -} - -fn is_read_only_command(command: &str) -> bool { - let mut parts = command.split_whitespace(); - let first = parts.next().unwrap_or_default(); - matches!( - first, - "ls" | "find" - | "rg" - | "grep" - | "sed" - | "cat" - | "head" - | "tail" - | "wc" - | "pwd" - | "file" - | "du" - | "stat" - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn clean_label_normalizes_to_slug_like_text() { - assert_eq!(clean_label("My Image 01"), "my-image-01"); - } - - #[test] - fn normalize_skill_spec_strips_legacy_prefixes() { - assert_eq!(normalize_skill_spec("skillplus:brand-logo"), "brand-logo"); - assert_eq!( - normalize_skill_spec("path:/tmp/brand.skillplus"), - "/tmp/brand.skillplus" - ); - } - - #[test] - fn normalize_locale_accepts_common_aliases() { - assert_eq!(normalize_locale("zh"), "zh-CN"); - assert_eq!(normalize_locale("EN-US"), "en"); - assert_eq!(normalize_locale("fr"), "fr"); - } - - #[test] - fn first_binary_skips_common_shell_wrappers() { - assert_eq!(first_binary("FOO=bar env /usr/bin/rg hello"), "rg"); - assert_eq!(first_binary("sudo time ls -la"), "ls"); - } -} diff --git a/scripts/release.sh b/scripts/release.sh index a4efef6..1a2117a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,107 +1,52 @@ #!/usr/bin/env bash -# Release a new openmelon version. +# Release a new openmelon version (pure-TypeScript npm package in tui/). # -# ./scripts/release.sh v0.2.0 # tag + build + GitHub release + npm publish -# ./scripts/release.sh v0.2.0 --dry-run +# ./scripts/release.sh v0.4.0 # tag + build + npm publish +# ./scripts/release.sh v0.4.0 --dry-run # -# Refuses to run with an unclean working tree. Builds darwin/linux × -# amd64/arm64 binaries with the version baked in via -ldflags. Bumps -# npm/package.json so @e8s/openmelon's postinstall fetches binaries -# from the matching GitHub Release. +# Refuses to run with an unclean working tree. Builds tui/ and publishes +# @e8s/openmelon to npm (prepublishOnly compiles dist/). No native binaries +# are produced — openmelon is plain Node now. set -euo pipefail VERSION="${1:-}" DRY_RUN="" -[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=1 +[ "${2:-}" = "--dry-run" ] && DRY_RUN="1" -if [[ -z "$VERSION" ]]; then - echo "usage: $0 [--dry-run]" >&2 - echo " e.g. $0 v0.2.0" >&2 +if [ -z "$VERSION" ]; then + echo "usage: $0 vX.Y.Z [--dry-run]" >&2 exit 2 fi +case "$VERSION" in + v*) ;; + *) echo "version must start with 'v' (e.g. v0.4.0)" >&2; exit 2 ;; +esac -if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.]+)?$ ]]; then - echo "version must look like v0.2.0 or v0.2.0-rc1, got: $VERSION" >&2 - exit 2 -fi - -NPM_VERSION="${VERSION#v}" # 0.2.0 (no v prefix for npm) - -if [[ -n "$(git status --porcelain)" ]]; then - echo "working tree is dirty; commit or stash first" >&2 - git status --short - exit 1 -fi - -if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo "tag $VERSION already exists" >&2 +if [ -n "$(git status --porcelain)" ]; then + echo "working tree is not clean; commit or stash first" >&2 exit 1 fi ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DIST="$ROOT/dist/$VERSION" -mkdir -p "$DIST" - -LDFLAGS="-X github.com/eight-acres-lab/openmelon/internal/version.Version=$VERSION -s -w" +cd "$ROOT/tui" -build_one() { - local goos="$1" goarch="$2" - local out="$DIST/openmelon-${VERSION}-${goos}-${goarch}" - [[ "$goos" == "windows" ]] && out="$out.exe" - echo " → $goos/$goarch" - GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \ - go build -ldflags "$LDFLAGS" -o "$out" ./cmd/openmelon - (cd "$DIST" && shasum -a 256 "$(basename "$out")" >> SHASUMS256.txt) -} +# Sync package.json version to the tag (strip leading v). +npm version "${VERSION#v}" --no-git-tag-version -echo "==> running tests" -go test ./... > /dev/null +npm install --ignore-scripts +npm run check +npm run build -echo "==> building binaries → $DIST" -rm -f "$DIST/SHASUMS256.txt" -build_one darwin amd64 -build_one darwin arm64 -build_one linux amd64 -build_one linux arm64 - -echo "==> built artifacts:" -ls -lh "$DIST" - -echo "==> bumping npm/package.json to $NPM_VERSION" -(cd "$ROOT/npm" && npm version "$NPM_VERSION" --no-git-tag-version --allow-same-version > /dev/null) - -if [[ -n "$DRY_RUN" ]]; then - echo "==> --dry-run: showing what npm would publish" - (cd "$ROOT/npm" && npm publish --dry-run --access public) - echo "==> --dry-run; reverting npm bump and stopping before tag + release" - (cd "$ROOT/npm" && git checkout -- package.json) +if [ -n "$DRY_RUN" ]; then + echo "[dry-run] would: npm publish --access public; git tag $VERSION" + npm pack --dry-run exit 0 fi -echo "==> committing npm version bump (if needed)" -git add npm/package.json -if git diff --cached --quiet; then - echo " (no version-bump changes — npm/package.json already at $NPM_VERSION)" -else - git commit -s -m "chore: release $VERSION" -fi - -echo "==> tagging $VERSION" -git tag -a "$VERSION" -m "Release $VERSION" -git push origin main "$VERSION" - -echo "==> creating GitHub release" -gh release create "$VERSION" \ - --title "$VERSION" \ - --generate-notes \ - "$DIST"/openmelon-* \ - "$DIST"/SHASUMS256.txt - -echo "==> npm publish (@e8s/openmelon@$NPM_VERSION)" -(cd "$ROOT/npm" && npm publish --access public) - -echo "" -echo "==> done." -echo " GitHub: https://github.com/eight-acres-lab/openmelon/releases/tag/$VERSION" -echo " npm: https://www.npmjs.com/package/@e8s/openmelon/v/$NPM_VERSION" +npm publish --access public +cd "$ROOT" +git add tui/package.json +git commit -m "chore: release $VERSION" +git tag "$VERSION" +echo "Published @e8s/openmelon $VERSION. Push with: git push && git push --tags" diff --git a/tui/package-lock.json b/tui/package-lock.json index 05ea62a..9def297 100644 --- a/tui/package-lock.json +++ b/tui/package-lock.json @@ -8,7 +8,10 @@ "name": "@e8s/openmelon-tui", "version": "0.1.0", "dependencies": { + "@e8s/vbox-cli": "^0.4.1", + "ansi-regex": "^6.2.2", "ink": "^5.2.1", + "marked": "^18.0.4", "react": "^18.3.1", "string-width": "^7.2.0", "wrap-ansi": "^9.0.0" @@ -36,6 +39,21 @@ "node": ">=14.13.1" } }, + "node_modules/@e8s/vbox-cli": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@e8s/vbox-cli/-/vbox-cli-0.4.1.tgz", + "integrity": "sha512-QuguFH4sjTn9mKpbX3b1sfpJZUViJnR/8vKDJ9KO5q5Jy9zfk13uSpD73TJ+IpK2pT00470FOm4iRkrzzfjNIg==", + "license": "Apache-2.0", + "dependencies": { + "commander": "^12.1.0" + }, + "bin": { + "vbox-cli": "bin/vbox-cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -640,6 +658,15 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -867,6 +894,18 @@ "loose-envify": "cli.js" } }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", diff --git a/tui/package.json b/tui/package.json index ec6a425..cbb81bf 100644 --- a/tui/package.json +++ b/tui/package.json @@ -1,19 +1,46 @@ { - "name": "@e8s/openmelon-tui", - "version": "0.1.0", - "private": true, + "name": "@e8s/openmelon", + "version": "0.4.0", + "description": "A content-creation agent that runs in your terminal. Pure TypeScript.", + "license": "Apache-2.0", + "homepage": "https://github.com/eight-acres-lab/openmelon", + "repository": { + "type": "git", + "url": "git+https://github.com/eight-acres-lab/openmelon.git", + "directory": "tui" + }, + "bugs": { + "url": "https://github.com/eight-acres-lab/openmelon/issues" + }, + "keywords": [ + "openmelon", + "ai-agent", + "content-generation", + "multimodal", + "skillplus", + "cli" + ], "type": "module", "bin": { "openmelon": "bin/openmelon.js" }, + "files": [ + "dist", + "bin" + ], "scripts": { "dev": "tsx src/cli.ts", "check": "tsc --noEmit", + "test": "npm run build && node --test dist/**/*.test.js", "build": "tsc -p tsconfig.build.json", - "start": "node dist/cli.js" + "start": "node dist/cli.js", + "prepublishOnly": "npm run build" }, "dependencies": { + "@e8s/vbox-cli": "^0.4.1", + "ansi-regex": "^6.2.2", "ink": "^5.2.1", + "marked": "^18.0.4", "react": "^18.3.1", "string-width": "^7.2.0", "wrap-ansi": "^9.0.0" @@ -23,5 +50,8 @@ "@types/react": "^18.3.20", "tsx": "^4.20.0", "typescript": "^5.8.0" + }, + "engines": { + "node": ">=20" } } diff --git a/tui/src/App.tsx b/tui/src/App.tsx index 64bc039..07bf359 100644 --- a/tui/src/App.tsx +++ b/tui/src/App.tsx @@ -1,17 +1,19 @@ import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; -import {Box, useApp, useInput, useStdout} from 'ink'; +import {Box, Static, useApp, useInput, useStdout} from 'ink'; import {filterSlashCommands, slashCommands} from './commands.js'; import {HeaderCard} from './components/Header.js'; import {PromptInput} from './components/PromptInput.js'; import {SelectorPanel, type SelectorRow} from './components/SelectorPanel.js'; import {SlashPalette} from './components/SlashPalette.js'; import {StatusLine} from './components/StatusLine.js'; -import {Transcript} from './components/Transcript.js'; -import {createRuntimeBridge, type RuntimeBridge, type RuntimeEvent} from './runtime/processBridge.js'; +import {Transcript, transcriptItemPlainText} from './components/Transcript.js'; +import {WorkingLine} from './components/WorkingLine.js'; +import {createRuntimeClient, type RuntimeClient, type RuntimeEvent} from './runtime/index.js'; import {randomPlaceholder} from './placeholder.js'; import {initialState, reducer} from './state/reducer.js'; import {discoverProject} from './core/project.js'; import {loadProject, saveProject} from './core/project.js'; +import {setGlobalDefaults} from './core/config.js'; import {loadSessionEvents, loadSessionHistory, sessionDir, type ChatMessage} from './core/session.js'; import {inspectBootstrap, type BootstrapState} from './core/bootstrap.js'; import {Onboarding} from './onboarding/Onboarding.js'; @@ -24,6 +26,8 @@ import type {TuiAction, TuiState, TranscriptItem} from './state/types.js'; type Props = { resumeId?: string; + initialPrompt?: string; + onSessionInfo?: (info: {sessionId?: string; sessionDir?: string}) => void; }; type Overlay = @@ -32,7 +36,7 @@ type Overlay = | {kind: 'settings'; cursor: number} | {kind: 'skill'; cursor: number; skills: SkillInfo[]; error: string} | {kind: 'custom-model'; cursor: number; image: boolean; input: string} - | {kind: 'approval'; cursor: number; id: string; tool: string; command: string; description: string; binary: string}; + | {kind: 'approval'; cursor: number; scroll: number; id: string; tool: string; command: string; description: string; binary: string}; type Dispatch = React.Dispatch; @@ -42,16 +46,23 @@ type OverlayRow = SelectorRow & { section?: boolean; }; -export function App({resumeId}: Props) { +type StaticRecord = + | {key: 'header'; kind: 'header'; state: TuiState} + | {key: string; kind: 'item'; item: TranscriptItem}; + +export function App({resumeId, initialPrompt, onSessionInfo}: Props) { const {exit} = useApp(); const {stdout} = useStdout(); const width = Math.max(32, stdout.columns ?? 88); + const height = Math.max(16, stdout.rows ?? 32); const [state, dispatch] = useReducer(reducer, undefined, initialState); const placeholder = useMemo(() => randomPlaceholder(), []); const [running, setRunning] = useState(false); + const [, setClock] = useState(0); const [bootstrap, setBootstrap] = useState(null); const [overlay, setOverlay] = useState(null); - const runtimeBridge = useRef(null); + const [staticEpoch, setStaticEpoch] = useState(0); + const runtimeBridge = useRef(null); const stateRef = useRef(state); const submittedRef = useRef(false); @@ -95,6 +106,7 @@ export function App({resumeId}: Props) { const handleRuntimeEvent = useCallback((event: RuntimeEvent) => { switch (event.type) { case 'ready': + onSessionInfo?.({sessionId: event.sessionId, sessionDir: event.sessionDir}); dispatch({ type: 'runtime-ready', model: event.model, @@ -102,18 +114,32 @@ export function App({resumeId}: Props) { project: event.project, provider: event.provider, sessionId: event.sessionId, - sessionDir: event.sessionDir + sessionDir: event.sessionDir, + clearSession: event.clearSession }); dispatch({type: 'status', status: 'ready', activity: event.activity ?? 'Ready'}); break; case 'append': - if (event.kind === 'error' && stateRef.current.items.length === 0 && !submittedRef.current) { + if (event.transient) { + dispatch({type: 'command-panel', kind: event.kind, text: event.text, markdown: event.markdown}); + } else if (event.kind === 'error' && stateRef.current.items.length === 0 && !submittedRef.current) { dispatch({type: 'notice', notice: event.text}); + } else if (event.delta) { + dispatch({type: 'append-delta', kind: event.kind, text: event.text, markdown: event.markdown ?? event.kind === 'assistant'}); } else { dispatch({type: 'append', kind: event.kind, text: event.text}); } break; + case 'pending-applied': + for (const text of event.texts) { + dispatch({type: 'commit-input', text}); + } + dispatch({type: 'pending-applied', count: event.texts.length}); + break; case 'status': + if ((event.status === 'thinking' || event.status === 'tool') && !stateRef.current.runStartedAt) { + dispatch({type: 'turn-started', at: Date.now()}); + } dispatch({type: 'status', status: event.status, activity: event.activity}); if (event.status === 'ready' || event.status === 'error') { setRunning(false); @@ -130,6 +156,7 @@ export function App({resumeId}: Props) { setOverlay({ kind: 'approval', cursor: 0, + scroll: 0, id: event.detail?.id ?? '', tool: event.detail?.tool ?? 'tool', command: event.detail?.command ?? '', @@ -140,7 +167,6 @@ export function App({resumeId}: Props) { break; case 'done': setRunning(false); - dispatch({type: 'drain-pending'}); break; case 'error': dispatch({type: 'append', kind: 'error', text: event.error}); @@ -153,15 +179,15 @@ export function App({resumeId}: Props) { if (!bootstrap?.ready) { return; } - runtimeBridge.current = createRuntimeBridge(handleRuntimeEvent, {resumeId}); + runtimeBridge.current = createRuntimeClient(handleRuntimeEvent, {resumeId, initialPrompt}); return () => runtimeBridge.current?.shutdown(); - }, [bootstrap?.ready, handleRuntimeEvent, resumeId]); + }, [bootstrap?.ready, handleRuntimeEvent, initialPrompt, resumeId]); const reloadRuntime = useCallback(() => { if (runtimeBridge.current?.isAvailable()) { runtimeBridge.current.reload(); } else { - runtimeBridge.current = createRuntimeBridge(handleRuntimeEvent, {resumeId}); + runtimeBridge.current = createRuntimeClient(handleRuntimeEvent, {resumeId}); } }, [handleRuntimeEvent, resumeId]); @@ -186,7 +212,7 @@ export function App({resumeId}: Props) { } dispatch({type: 'append', kind: 'info', text: 'continue below'}); })().catch(error => { - dispatch({type: 'append', kind: 'error', text: `resume: ${(error as Error).message}`}); + dispatch({type: 'append', kind: 'info', text: `resume ${resumeId} unavailable; starting fresh. ${(error as Error).message}`}); }); return () => { cancelled = true; @@ -202,27 +228,37 @@ export function App({resumeId}: Props) { }, [filteredCommands.length, state.paletteIndex]); const startTurn = useCallback((text: string) => { + submittedRef.current = true; setRunning(true); + dispatch({type: 'turn-started', at: Date.now()}); runtimeBridge.current?.run(text); }, []); + useEffect(() => { + if (!running) { + return; + } + const timer = setInterval(() => setClock(value => value + 1), 1000); + return () => clearInterval(timer); + }, [running]); + const submit = useCallback((raw: string) => { let text = raw.trim(); if (text.length === 0) { return; } - submittedRef.current = true; - dispatch({type: 'submit-start', text}); - if (matchesExit(text)) { runtimeBridge.current?.shutdown(); exit(); return; } + if (text.startsWith('/')) { + dispatch({type: 'clear-input', remember: true}); + } if (text === '/help' || text === '/?') { dispatch({ - type: 'append', + type: 'command-panel', kind: 'info', text: 'Commands:\n' + slashCommands.map(command => ` ${command.name.padEnd(14)} ${command.help}`).join('\n') }); @@ -231,21 +267,24 @@ export function App({resumeId}: Props) { if (text === '/copy') { const body = transcriptText(stateRef.current.items); if (!body.trim()) { - dispatch({type: 'append', kind: 'error', text: 'nothing to copy'}); + dispatch({type: 'command-panel', kind: 'error', text: 'nothing to copy'}); return; } osc52Copy(body); - dispatch({type: 'append', kind: 'info', text: `copied transcript (${Array.from(body).length} chars)`}); + dispatch({type: 'command-panel', kind: 'info', text: `copied transcript (${Array.from(body).length} chars)`}); return; } if (text === '/clear') { + submittedRef.current = false; + setStaticEpoch(value => value + 1); dispatch({type: 'clear-transcript'}); + clearTerminalScreen(); runtimeCommand(runtimeBridge.current, dispatch, bridge => bridge.clearHistory()); return; } if (text === '/status') { dispatch({ - type: 'append', + type: 'command-panel', kind: 'info', text: `project ${stateRef.current.project} · provider ${stateRef.current.provider || 'default'} · model ${stateRef.current.model} · reasoning ${stateRef.current.reasoning} · session ${stateRef.current.sessionId || '(starting)'}` }); @@ -259,10 +298,10 @@ export function App({resumeId}: Props) { const arg = text.split(/\s+/, 2)[1] ?? ''; if (['clear', 'off', 'none'].includes(arg)) { dispatch({type: 'set-active-skill', skill: ''}); - dispatch({type: 'append', kind: 'info', text: '(skill cleared)'}); + dispatch({type: 'command-panel', kind: 'info', text: '(skill cleared)'}); } else { dispatch({type: 'set-active-skill', skill: arg}); - dispatch({type: 'append', kind: 'info', text: `(skill: ${arg}) — applies to your next message`}); + dispatch({type: 'command-panel', kind: 'info', text: `(skill: ${arg}) — applies to your next message`}); } return; } @@ -273,7 +312,7 @@ export function App({resumeId}: Props) { if (text.startsWith('/save')) { const [, rawPath] = text.split(/\s+/, 2); if (!rawPath) { - dispatch({type: 'append', kind: 'error', text: '/save: usage: /save '}); + dispatch({type: 'command-panel', kind: 'error', text: '/save: usage: /save '}); return; } runtimeCommand(runtimeBridge.current, dispatch, bridge => bridge.save(rawPath)); @@ -282,7 +321,7 @@ export function App({resumeId}: Props) { if (text === '/session') { const current = stateRef.current; const dir = current.sessionDir || (bootstrap?.workdir && current.sessionId ? sessionDir(bootstrap.workdir, current.sessionId) : ''); - dispatch({type: 'append', kind: 'info', text: dir || '(session not ready)'}); + dispatch({type: 'command-panel', kind: 'info', text: dir || '(session not ready)'}); return; } if (text === '/events') { @@ -303,7 +342,7 @@ export function App({resumeId}: Props) { } if (text.startsWith('/model ')) { if (!bootstrap) { - dispatch({type: 'append', kind: 'error', text: 'bootstrap is not ready'}); + dispatch({type: 'command-panel', kind: 'error', text: 'bootstrap is not ready'}); return; } void applyModelCommand(text, bootstrap, dispatch, reloadRuntime, refreshBootstrap); @@ -315,7 +354,7 @@ export function App({resumeId}: Props) { } if (text.startsWith('/model-image ')) { if (!bootstrap) { - dispatch({type: 'append', kind: 'error', text: 'bootstrap is not ready'}); + dispatch({type: 'command-panel', kind: 'error', text: 'bootstrap is not ready'}); return; } void applyImageModelCommand(text, bootstrap, dispatch, reloadRuntime, refreshBootstrap); @@ -327,17 +366,18 @@ export function App({resumeId}: Props) { } if (text.startsWith('/settings ') || text.startsWith('/config ')) { if (!bootstrap) { - dispatch({type: 'append', kind: 'error', text: 'bootstrap is not ready'}); + dispatch({type: 'command-panel', kind: 'error', text: 'bootstrap is not ready'}); return; } void applySettingsCommand(text, bootstrap, dispatch, reloadRuntime, refreshBootstrap); return; } if (text.startsWith('/')) { - dispatch({type: 'append', kind: 'error', text: `unknown command: ${text.split(/\s+/)[0]} (try /help)`}); + dispatch({type: 'command-panel', kind: 'error', text: `unknown command: ${text.split(/\s+/)[0]} (try /help)`}); return; } + const visibleText = text; const skill = stateRef.current.activeSkill; if (skill) { text = `Apply the skill "${skill}" to this request: first call compile_skill with skill="${skill}" (BARE slug, no 'skillplus:' prefix) to fetch the package's prompt + output schema, then proceed.\n\n${text}`; @@ -345,13 +385,16 @@ export function App({resumeId}: Props) { } if (running) { + submittedRef.current = true; dispatch({type: 'queue-pending', text}); - dispatch({type: 'append', kind: 'info', text: `queued pending input: ${text}`}); + dispatch({type: 'clear-input', remember: true}); runtimeBridge.current?.pending(text); return; } - if (runtimeBridge.current && !runtimeBridge.current.isAvailable()) { + dispatch({type: 'commit-input', text: visibleText}); + + if (!runtimeBridge.current || !runtimeBridge.current.isAvailable()) { dispatch({type: 'append', kind: 'error', text: stateRef.current.notice || 'runtime unavailable'}); return; } @@ -394,6 +437,8 @@ export function App({resumeId}: Props) { } if (overlay.kind === 'approval') { const rows = approvalRows(overlay); + const bodyRows = approvalBodyRows(); + const maxScroll = approvalMaxScroll(overlay, bodyRows); if (key.upArrow || chunk === 'k') { setOverlay({...overlay, cursor: Math.max(0, overlay.cursor - 1)}); return; @@ -402,6 +447,22 @@ export function App({resumeId}: Props) { setOverlay({...overlay, cursor: Math.min(rows.length - 1, overlay.cursor + 1)}); return; } + if (key.pageUp || (key.ctrl && chunk === 'u')) { + setOverlay({...overlay, scroll: Math.max(0, overlay.scroll - bodyRows)}); + return; + } + if (key.pageDown || (key.ctrl && chunk === 'd')) { + setOverlay({...overlay, scroll: Math.min(maxScroll, overlay.scroll + bodyRows)}); + return; + } + if (isHomeKey(chunk)) { + setOverlay({...overlay, scroll: 0}); + return; + } + if (isEndKey(chunk)) { + setOverlay({...overlay, scroll: maxScroll}); + return; + } if (key.escape || chunk === 'n' || chunk === 'N') { runtimeBridge.current?.approval(overlay.id, false, false); setOverlay(null); @@ -498,6 +559,12 @@ export function App({resumeId}: Props) { } if (key.ctrl && chunk === 'c') { + if (running) { + runtimeBridge.current?.cancel(); + setRunning(false); + dispatch({type: 'status', status: 'error', activity: 'Interrupted'}); + return; + } if (current.input.trim().length > 0) { dispatch({type: 'clear-input', notice: 'input cleared', remember: true}); return; @@ -512,7 +579,19 @@ export function App({resumeId}: Props) { return; } + if (key.ctrl && chunk === 'd') { + runtimeBridge.current?.shutdown(); + exit(); + return; + } + if (key.escape) { + if (running) { + runtimeBridge.current?.cancel(); + setRunning(false); + dispatch({type: 'status', status: 'error', activity: 'Interrupted'}); + return; + } if (current.input.trim().length > 0) { dispatch({type: 'clear-input', notice: 'input cleared', remember: true}); } else { @@ -534,12 +613,45 @@ export function App({resumeId}: Props) { return; } + if (key.leftArrow) { + dispatch({type: 'move-input', movement: 'left'}); + return; + } + if (key.rightArrow) { + dispatch({type: 'move-input', movement: 'right'}); + return; + } + if ((key.ctrl && chunk === 'a') || isHomeKey(chunk)) { + dispatch({type: 'move-input', movement: 'line-start'}); + return; + } + if ((key.ctrl && chunk === 'e') || isEndKey(chunk)) { + dispatch({type: 'move-input', movement: 'line-end'}); + return; + } + if (key.upArrow) { - dispatch({type: 'history-prev'}); + if (running && current.input.length === 0 && current.pendingInputs.length > 0) { + const pendingTexts = [...current.pendingInputs]; + for (const pending of current.pendingInputs) { + runtimeBridge.current?.unpending(pending); + } + dispatch({type: 'recall-pending', texts: pendingTexts}); + return; + } + if (current.input.length === 0 || current.historyIndex !== null) { + dispatch({type: 'history-prev'}); + } else { + dispatch({type: 'move-input', movement: 'up', width: promptInputWidth(width)}); + } return; } if (key.downArrow) { - dispatch({type: 'history-next'}); + if (current.input.length === 0 || current.historyIndex !== null) { + dispatch({type: 'history-next'}); + } else { + dispatch({type: 'move-input', movement: 'down', width: promptInputWidth(width)}); + } return; } @@ -557,10 +669,14 @@ export function App({resumeId}: Props) { return; } - if (key.backspace || key.delete) { + if (key.backspace || isBackspaceKey(chunk) || (key.delete && !isDeleteKey(chunk))) { dispatch({type: 'backspace'}); return; } + if (isDeleteKey(chunk)) { + dispatch({type: 'delete-forward'}); + return; + } if (key.ctrl) { return; @@ -580,17 +696,41 @@ export function App({resumeId}: Props) { return ; } + const streaming = running || state.status === 'thinking' || state.status === 'tool'; + const lastItem = state.items.at(-1); + const liveItems = streaming && lastItem?.kind === 'assistant' ? [lastItem] : []; + const stableItems = liveItems.length > 0 ? state.items.slice(0, -1) : state.items; + const staticRecords: StaticRecord[] = [ + {key: 'header', kind: 'header', state}, + ...stableItems.map(item => ({key: `item-${item.id}`, kind: 'item' as const, item})) + ]; + const liveLineBudget = Math.max(6, Math.min(16, Math.floor(height * 0.4))); + return ( - - - + + {record => + record.kind === 'header' ? ( + + + + ) : ( + + + + ) + } + + {liveItems.length > 0 && } + + {state.commandPanel && } + {overlay ? ( ) : filteredCommands.length > 0 ? ( - + ) : null} - {!overlay && } + {!overlay && } @@ -601,6 +741,12 @@ function matchesExit(text: string) { return text === '/exit' || text === '/quit' || text === '/q'; } +function clearTerminalScreen() { + if (process.stdout.isTTY) { + process.stdout.write('\u001Bc'); + } +} + function isShiftEnter(chunk: string, key: {return?: boolean; shift?: boolean}) { if (key.return && key.shift) { return true; @@ -615,6 +761,26 @@ function isShiftEnter(chunk: string, key: {return?: boolean; shift?: boolean}) { ); } +function isHomeKey(chunk: string) { + return chunk === '\u001B[H' || chunk === '\u001B[1~' || chunk === '\u001BOH'; +} + +function isEndKey(chunk: string) { + return chunk === '\u001B[F' || chunk === '\u001B[4~' || chunk === '\u001BOF'; +} + +function isDeleteKey(chunk: string) { + return chunk === '\u001B[3~'; +} + +function isBackspaceKey(chunk: string) { + return chunk === '\u007F' || chunk === '\b' || chunk === '\u0008'; +} + +function promptInputWidth(width: number) { + return Math.max(12, width - 2 - 6); +} + function normalizeTextInput(chunk: string) { if (!chunk || chunk.includes('\u001B')) { return ''; @@ -655,9 +821,6 @@ function renderHistory(messages: ChatMessage[]) { case 'tool': { const tool = message.tool_call_id ? toolNames.get(message.tool_call_id) : ''; if (tool === 'finish') { - if (message.content?.trim()) { - output.push({kind: 'assistant', text: finishText(message.content)}); - } break; } output.push({kind: 'result', text: `└ ${compactToolContent(message.content ?? '')}`}); @@ -694,15 +857,6 @@ function compactToolContent(content: string) { return truncateOneLine(content, 180); } -function finishText(content: string) { - try { - const parsed = JSON.parse(content) as {summary?: string}; - return parsed.summary?.trim() || content; - } catch { - return content; - } -} - function truncateOneLine(text: string, max: number) { const oneLine = text.replace(/\s+/g, ' ').trim(); return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine; @@ -712,14 +866,14 @@ function transcriptText(items: TranscriptItem[]) { return items .map(item => { const label = item.kind === 'assistant' ? 'assistant' : item.kind; - return `[${label}] ${item.text}`; + return `[${label}] ${transcriptItemPlainText(item)}`; }) .join('\n\n'); } -function runtimeCommand(bridge: RuntimeBridge | null, dispatch: Dispatch, run: (bridge: RuntimeBridge) => void) { +function runtimeCommand(bridge: RuntimeClient | null, dispatch: Dispatch, run: (bridge: RuntimeClient) => void) { if (!bridge || !bridge.isAvailable()) { - dispatch({type: 'append', kind: 'error', text: 'runtime unavailable'}); + dispatch({type: 'command-panel', kind: 'error', text: 'runtime unavailable'}); return; } run(bridge); @@ -727,16 +881,16 @@ function runtimeCommand(bridge: RuntimeBridge | null, dispatch: Dispatch, run: ( async function eventsCommand(workdir: string, sessionId: string, dispatch: Dispatch) { if (!workdir || !sessionId) { - dispatch({type: 'append', kind: 'error', text: '/events: session is not ready'}); + dispatch({type: 'command-panel', kind: 'error', text: '/events: session is not ready'}); return; } const events = await loadSessionEvents(workdir, sessionId, 20); if (events.length === 0) { - dispatch({type: 'append', kind: 'info', text: '(no events recorded yet)'}); + dispatch({type: 'command-panel', kind: 'info', text: '(no events recorded yet)'}); return; } dispatch({ - type: 'append', + type: 'command-panel', kind: 'info', text: events .map(event => `${event.type ?? 'event'} step=${event.step ?? 0} tool=${event.tool ?? ''} space=${event.space_id ?? ''} status=${event.status ?? ''}`) @@ -747,12 +901,12 @@ async function eventsCommand(workdir: string, sessionId: string, dispatch: Dispa async function spaceCommand(text: string, workdir: string, dispatch: Dispatch) { const [, id] = text.split(/\s+/, 2); if (!workdir || !id) { - dispatch({type: 'append', kind: 'error', text: '/space: usage: /space '}); + dispatch({type: 'command-panel', kind: 'error', text: '/space: usage: /space '}); return; } const summary = await summarizeSpace(workdir, id); dispatch({ - type: 'append', + type: 'command-panel', kind: 'info', text: `${summary.meta.id} (${summary.meta.status ?? 'unknown'}): ${summary.meta.name ?? ''}\n ${summary.decisions} decisions · ${summary.feedback} feedback · ${summary.episodes} episodes · ${summary.assets} assets` }); @@ -761,11 +915,11 @@ async function spaceCommand(text: string, workdir: string, dispatch: Dispatch) { async function compactCommand(text: string, workdir: string, dispatch: Dispatch) { const [, id] = text.split(/\s+/, 2); if (!workdir || !id) { - dispatch({type: 'append', kind: 'error', text: '/compact: usage: /compact '}); + dispatch({type: 'command-panel', kind: 'error', text: '/compact: usage: /compact '}); return; } const draft = await buildCompactionDraft(workdir, id); - dispatch({type: 'append', kind: 'assistant', text: draft || '(empty compaction draft)'}); + dispatch({type: 'command-panel', kind: 'assistant', text: draft || '(empty compaction draft)', markdown: true}); } async function applyModelCommand( @@ -778,7 +932,7 @@ async function applyModelCommand( const parts = text.split(/\s+/); const model = parts[1]?.trim(); if (!model) { - dispatch({type: 'append', kind: 'error', text: '/model: usage: /model '}); + dispatch({type: 'command-panel', kind: 'error', text: '/model: usage: /model '}); return; } await applyModelDefaults(bootstrap, dispatch, reloadRuntime, refreshBootstrap, { @@ -798,7 +952,7 @@ async function applyImageModelCommand( const first = parts[1]?.trim(); if (!first) { dispatch({ - type: 'append', + type: 'command-panel', kind: 'error', text: '/model-image: usage: /model-image | /model-image | /model-image off' }); @@ -833,39 +987,39 @@ async function applySettingsCommand( const value = parts[2]; if (!section || !value) { dispatch({ - type: 'append', + type: 'command-panel', kind: 'error', text: '/settings: usage: /settings bash strict|auto|trusted or /settings reasoning auto|medium|high|xhigh' }); return; } if (!bootstrap.workdir) { - dispatch({type: 'append', kind: 'error', text: 'project is not ready'}); + dispatch({type: 'command-panel', kind: 'error', text: 'project is not ready'}); return; } const project = await loadProject(bootstrap.workdir); project.settings = project.settings ?? {}; if (section === 'bash') { if (!['strict', 'auto', 'trusted'].includes(value)) { - dispatch({type: 'append', kind: 'error', text: '/settings bash: expected strict|auto|trusted'}); + dispatch({type: 'command-panel', kind: 'error', text: '/settings bash: expected strict|auto|trusted'}); return; } project.settings.bash_permission_mode = value as 'strict' | 'auto' | 'trusted'; } else if (section === 'reasoning') { if (!['auto', 'medium', 'high', 'xhigh'].includes(value)) { - dispatch({type: 'append', kind: 'error', text: '/settings reasoning: expected auto|medium|high|xhigh'}); + dispatch({type: 'command-panel', kind: 'error', text: '/settings reasoning: expected auto|medium|high|xhigh'}); return; } project.settings.reasoning_effort = value === 'auto' ? undefined : (value as ProjectSettings['reasoning_effort']); } else { - dispatch({type: 'append', kind: 'error', text: '/settings: expected bash or reasoning'}); + dispatch({type: 'command-panel', kind: 'error', text: '/settings: expected bash or reasoning'}); return; } await saveProject(bootstrap.workdir, project); await refreshBootstrap(); reloadRuntime(); dispatch({ - type: 'append', + type: 'command-panel', kind: 'info', text: `(settings: bash=${project.settings.bash_permission_mode || 'strict'} reasoning=${project.settings.reasoning_effort || 'auto'})` }); @@ -882,12 +1036,23 @@ function overlayView(overlay: Overlay, bootstrap: BootstrapState, state: TuiStat }; } if (overlay.kind === 'approval') { + const bodyRows = approvalBodyRows(); + const detail = approvalDetailText(overlay); + const lines = detail.split('\n'); + const max = approvalMaxScroll(overlay, bodyRows); + const start = Math.min(overlay.scroll, max); + const end = Math.min(lines.length, start + bodyRows); + const description = + lines.length > bodyRows + ? `Details ${start + 1}-${end}/${lines.length} · PgUp/PgDn scroll\n\n${lines.slice(start, end).join('\n')}` + : detail; return { - title: `Do you want to run ${overlay.tool}?`, - description: [overlay.description, overlay.command].filter(Boolean).join('\n\n'), + title: `${approvalToolLabel(overlay.tool)} approval required`, + description, rows: approvalRows(overlay), active: overlay.cursor, - footer: 'Enter to confirm · y=yes · n=no · Esc=no · 1/2/3 shortcut' + footer: 'Enter confirm · Esc deny · 1/2/3 shortcut · PgUp/PgDn details', + compact: true }; } if (overlay.kind === 'custom-model') { @@ -976,7 +1141,7 @@ async function openSkillOverlay(setOverlay: React.Dispatch, state: TuiState): function commitSkillRow(row: OverlayRow, dispatch: Dispatch) { dispatch({type: 'set-active-skill', skill: row.value}); dispatch({ - type: 'append', + type: 'command-panel', kind: 'info', text: row.value ? `(skill: ${row.value}) — applies to your next message` : '(skill cleared)' }); } function approvalRows(overlay: Extract): OverlayRow[] { + const scope = approvalScopeLabel(overlay); return [ {id: 'yes', value: 'yes', title: 'Yes'}, - {id: 'always', value: 'always', title: `Yes, always allow \`${overlay.binary || 'this binary'}\` this session`}, + {id: 'always', value: 'always', title: `Yes, always allow ${scope} in this project`}, {id: 'no', value: 'no', title: 'No'} ]; } -function answerApproval(bridge: RuntimeBridge | null, overlay: Extract, index: number) { +function approvalDetailText(overlay: Extract) { + const parts: string[] = []; + if (overlay.description.trim()) { + parts.push(`Reason\n${overlay.description.trim()}`); + } + if (overlay.command.trim()) { + parts.push(`${overlay.tool === 'bash' ? 'Command' : 'Target'}\n${overlay.command.trim()}`); + } + return parts.join('\n\n') || 'Review the request before approving.'; +} + +function approvalToolLabel(tool: string) { + if (tool === 'web_search') { + return 'Web search'; + } + if (tool === 'web_fetch') { + return 'Web fetch'; + } + return tool === 'bash' ? 'Bash' : tool; +} + +function approvalScopeLabel(overlay: Extract) { + if (overlay.tool === 'web_search') { + return 'web searches through DuckDuckGo'; + } + if (overlay.tool === 'web_fetch') { + return `fetching ${overlay.binary || 'this host'}`; + } + return `\`${overlay.binary || 'this binary'}\``; +} + +function approvalBodyRows() { + return 8; +} + +function approvalMaxScroll(overlay: Extract, bodyRows = approvalBodyRows()) { + return Math.max(0, approvalDetailText(overlay).split('\n').length - bodyRows); +} + +function answerApproval(bridge: RuntimeClient | null, overlay: Extract, index: number) { switch (index) { case 0: bridge?.approval(overlay.id, true, false); @@ -1041,11 +1246,11 @@ async function applyOverlaySelection( return; } if (!bootstrap.workdir) { - dispatch({type: 'append', kind: 'error', text: 'project is not ready'}); + dispatch({type: 'command-panel', kind: 'error', text: 'project is not ready'}); return; } if (row.value === 'custom') { - dispatch({type: 'append', kind: 'info', text: 'custom model input is not wired yet; edit project defaults or run setup with flags.'}); + dispatch({type: 'command-panel', kind: 'info', text: 'custom model input is not wired yet; edit project defaults or run setup with flags.'}); return; } if (overlay.kind === 'model') { @@ -1070,7 +1275,7 @@ async function applyOverlaySelection( project.settings.reasoning_effort = (row.value || undefined) as ProjectSettings['reasoning_effort']; } await saveProject(bootstrap.workdir, project); - dispatch({type: 'append', kind: 'info', text: `(settings updated: ${row.title})`}); + dispatch({type: 'command-panel', kind: 'info', text: `(settings updated: ${row.title})`}); await refreshBootstrap(); reloadRuntime(); } @@ -1106,18 +1311,11 @@ async function applyModelDefaults( refreshBootstrap: () => Promise, next: {provider: ProviderOption['slug']; model: string} ) { - if (!bootstrap.workdir) { - dispatch({type: 'append', kind: 'error', text: 'project is not ready'}); - return; - } - const project = await loadProject(bootstrap.workdir); - project.defaults = project.defaults ?? {}; - project.defaults.llm_provider = next.provider; - project.defaults.llm_model = next.model; - await saveProject(bootstrap.workdir, project); + // Model / provider are GLOBAL (~/.openmelon/config.json), never per-project. + await setGlobalDefaults({llm_provider: next.provider, llm_model: next.model}); await refreshBootstrap(); reloadRuntime(); - dispatch({type: 'append', kind: 'info', text: `(LLM: ${composeModelTag(next.provider, next.model)})`}); + dispatch({type: 'command-panel', kind: 'info', text: `(LLM: ${composeModelTag(next.provider, next.model)})`}); } async function applyImageDefaults( @@ -1127,19 +1325,12 @@ async function applyImageDefaults( refreshBootstrap: () => Promise, next: {provider: string; model: string} ) { - if (!bootstrap.workdir) { - dispatch({type: 'append', kind: 'error', text: 'project is not ready'}); - return; - } - const project = await loadProject(bootstrap.workdir); - project.defaults = project.defaults ?? {}; - project.defaults.image_provider = next.model ? next.provider : ''; - project.defaults.image_model = next.model; - await saveProject(bootstrap.workdir, project); + // Image model / provider are GLOBAL, never per-project. + await setGlobalDefaults({image_provider: next.model ? next.provider : '', image_model: next.model}); await refreshBootstrap(); reloadRuntime(); dispatch({ - type: 'append', + type: 'command-panel', kind: 'info', text: next.model ? `(image model: ${composeModelTag(next.provider, next.model)})` : '(image generation disabled)' }); @@ -1174,11 +1365,11 @@ function imageProviderFor(bootstrap: BootstrapState, state: TuiState) { } function bootstrapProjectImageProvider(bootstrap: BootstrapState) { - return bootstrap.project?.defaults?.image_provider ?? ''; + return bootstrap.imageProvider ?? ''; } function bootstrapProjectImageModel(bootstrap: BootstrapState) { - return bootstrap.project?.defaults?.image_model ?? ''; + return bootstrap.imageModel ?? ''; } function bootstrapProjectSettings(bootstrap: BootstrapState) { diff --git a/tui/src/cli.ts b/tui/src/cli.ts index ef57e9b..d45adf1 100644 --- a/tui/src/cli.ts +++ b/tui/src/cli.ts @@ -1,26 +1,14 @@ -import {spawn} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; import {discoverProject} from './core/project.js'; import {listSessions, validateSessionWorkspace} from './core/session.js'; import {runTui} from './main.js'; import {runInitCommand} from './commands/init.js'; import {runSetupCommand} from './commands/setup.js'; - -const legacySubcommands = new Set([ - 'project', - 'character', - 'reference', - 'material', - 'search', - 'space', - 'session', - 'runtime-bridge', - 'help', - '-h', - '--help' -]); +import {runProjectCommand} from './commands/project.js'; +import {runRegistryCommand} from './commands/registry.js'; +import {runSearchCommand} from './commands/search.js'; +import {runSessionCommand} from './commands/session.js'; +import {runSpaceCommand} from './commands/space.js'; +import {createRuntimeClient, type RuntimeClient, type RuntimeEvent} from './runtime/index.js'; export async function main(argv = process.argv.slice(2)) { const [command, ...rest] = argv; @@ -45,14 +33,117 @@ export async function main(argv = process.argv.slice(2)) { return; } - if (legacySubcommands.has(command) || command.startsWith('-')) { - await runLegacy(argv); + if (command === 'project') { + await runProjectCommand(rest); + return; + } + + if (command === 'character' || command === 'reference' || command === 'material') { + await runRegistryCommand(command, rest); + return; + } + + if (command === 'search') { + await runSearchCommand(rest); + return; + } + + if (command === 'space') { + await runSpaceCommand(rest); + return; + } + + if (command === 'session') { + await runSessionCommand(rest); + return; + } + + if (command === 'runtime-bridge') { + throw new Error('runtime-bridge was retired; the TypeScript runtime is native now'); + } + + if (command === 'help' || command === '-h' || command === '--help') { + printHelp(); + return; + } + + if (command === 'run' || command === '-p') { + const prompt = command === 'run' ? rest.join(' ') : rest.join(' '); + if (!prompt.trim()) { + throw new Error(command === '-p' ? 'usage: openmelon -p ' : 'usage: openmelon run '); + } + await runHeadless(prompt); return; } + if (command.startsWith('-')) { + throw new Error('unknown flag; use `openmelon --help` for TS-native commands'); + } + await runTui({argv}); } +async function runHeadless(prompt: string) { + let client: RuntimeClient | null = null; + let assistantStreaming = false; + let started = false; + await new Promise((resolve, reject) => { + const emit = (event: RuntimeEvent) => { + switch (event.type) { + case 'ready': + if (!started) { + started = true; + client?.run(prompt); + } + break; + case 'append': + if (event.kind === 'user') { + return; + } + if (event.delta && event.kind === 'assistant') { + process.stdout.write(event.text); + assistantStreaming = true; + return; + } + if (assistantStreaming) { + process.stdout.write('\n'); + assistantStreaming = false; + } + if (event.kind === 'assistant') { + console.log(event.text); + } else if (event.kind === 'error') { + console.error(event.text); + } else { + console.error(`[${event.kind}] ${event.text}`); + } + break; + case 'approval': + console.error(`[approval denied] ${event.detail?.description || event.detail?.command || event.detail?.tool || 'tool call'}`); + if (event.detail?.id) { + client?.approval(event.detail.id, false, false); + } + break; + case 'done': + if (assistantStreaming) { + process.stdout.write('\n'); + assistantStreaming = false; + } + client?.shutdown(); + resolve(); + break; + case 'error': + if (assistantStreaming) { + process.stdout.write('\n'); + assistantStreaming = false; + } + reject(new Error(event.error)); + break; + } + }; + client = createRuntimeClient(emit); + }); +} + async function handleResume(args: string[]) { const workdir = await discoverProject(); if (!workdir) { @@ -81,39 +172,6 @@ async function handleResume(args: string[]) { await runTui({argv: [], resumeId: id}); } -async function runLegacy(args: string[]) { - const binary = resolveLegacyBinary(); - await new Promise((resolve, reject) => { - const child = spawn(binary, args, {stdio: 'inherit', cwd: process.cwd(), env: process.env}); - child.on('error', reject); - child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - if (code && code !== 0) { - process.exitCode = code; - resolve(); - return; - } - resolve(); - }); - }); -} - -function resolveLegacyBinary() { - if (process.env.OPENMELON_RUNTIME_BIN) { - return process.env.OPENMELON_RUNTIME_BIN; - } - - const here = path.dirname(fileURLToPath(import.meta.url)); - const repoBinary = path.resolve(here, '..', '..', 'openmelon'); - if (existsSync(repoBinary)) { - return repoBinary; - } - return 'openmelon'; -} - function formatWhen(date: Date) { if (Number.isNaN(date.getTime())) { return '(unknown)'; @@ -121,6 +179,28 @@ function formatWhen(date: Date) { return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } +function printHelp() { + console.log('openmelon - content-creation agent for the terminal'); + console.log(''); + console.log('Interactive:'); + console.log(' openmelon Enter the TUI'); + console.log(' openmelon repl Same; explicit form'); + console.log(' openmelon resume [] Resume a prior session'); + console.log(''); + console.log('Subcommands:'); + console.log(' init [] Set up cwd as an openmelon project'); + console.log(' setup Run trust/auth/project setup'); + console.log(' project list|use|show|set-key|unset-key|keys'); + console.log(' character add|list|show|rm Project character library'); + console.log(' reference add|list|show|rm Project reference-image library'); + console.log(' material add|list Hash-addressed material pool'); + console.log(' space create|activate|list|show|context|search|decision|feedback|memory|promote|episode|asset|asset-weight|compact'); + console.log(' session events Inspect session lifecycle events'); + console.log(' search "" Search project libraries'); + console.log(''); + console.log('Runtime: TypeScript native only. Non-TS runtimes are not used by this entrypoint.'); +} + function truncate(text: string, max: number) { return text.length > max ? `${text.slice(0, max)}…` : text; } diff --git a/tui/src/commands.ts b/tui/src/commands.ts index 77b5236..ce78a30 100644 --- a/tui/src/commands.ts +++ b/tui/src/commands.ts @@ -6,19 +6,19 @@ export type SlashCommand = { export const slashCommands: SlashCommand[] = [ {name: '/help', help: 'show commands and keybindings'}, {name: '/?', help: 'show commands and keybindings'}, - {name: '/skill', help: 'pick or clear a skillplus package for the next message'}, {name: '/status', help: 'show project, model, reasoning, and token status'}, - {name: '/history', help: 'print the message log so far'}, - {name: '/save', help: 'write the conversation to a file (jsonl)'}, + {name: '/skill', help: 'pick or clear a skillplus package for the next message'}, + {name: '/compact', help: 'preview context compaction'}, {name: '/clear', help: 'forget the conversation history'}, {name: '/model', help: 'switch the text model'}, {name: '/model-image', help: 'switch or disable image generation'}, {name: '/settings', help: 'open project settings'}, {name: '/copy', help: 'copy transcript via OSC52 when wired'}, {name: '/session', help: 'show current session information'}, + {name: '/history', help: 'print the message log so far'}, + {name: '/save', help: 'write the conversation to a file (jsonl)'}, {name: '/events', help: 'show recent runtime events'}, {name: '/space', help: 'show creative-space memory summary'}, - {name: '/compact', help: 'preview context compaction'}, {name: '/exit', help: 'exit OpenMelon'} ]; diff --git a/tui/src/commands/common.ts b/tui/src/commands/common.ts new file mode 100644 index 0000000..db1d650 --- /dev/null +++ b/tui/src/commands/common.ts @@ -0,0 +1,158 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; +import {loadProjects, loadUserConfig} from '../core/config.js'; +import {discoverProject, loadProject, projectPath, type ProjectConfig} from '../core/project.js'; + +export type ResolvedProject = { + workdir: string; + project: ProjectConfig; +}; + +export type FlagSpec = Record; + +export type ParsedArgs = { + flags: Record; + positionals: string[]; +}; + +export async function resolveProjectWorkdir(args: string[] = []): Promise { + if (args.length >= 2 && args[0] === '-C') { + const workdir = path.resolve(args[1]!); + return {workdir, project: await loadProject(workdir)}; + } + + const discovered = await discoverProject(); + if (discovered) { + return {workdir: discovered, project: await loadProject(discovered)}; + } + + const config = await loadUserConfig(); + if (!config.current_project) { + throw new Error('no current project; run `openmelon init` in a project dir'); + } + const projects = await loadProjects(); + const entry = projects.entries.find(item => item.id === config.current_project); + if (!entry) { + throw new Error(`current project ${config.current_project} is not registered`); + } + return {workdir: entry.workdir, project: await loadProject(entry.workdir)}; +} + +export function parseArgs(args: string[], spec: FlagSpec = {}): ParsedArgs { + const flags: ParsedArgs['flags'] = {}; + const positionals: string[] = []; + let endOfFlags = false; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (endOfFlags) { + positionals.push(arg); + continue; + } + if (arg === '--') { + endOfFlags = true; + continue; + } + if (!arg.startsWith('-') || arg === '-') { + positionals.push(arg); + continue; + } + + const [rawName, inlineValue] = arg.replace(/^-+/, '').split('=', 2); + const name = rawName ?? ''; + const kind = spec[name] ?? 'string'; + if (kind === 'boolean') { + flags[name] = inlineValue === undefined ? true : inlineValue !== 'false'; + continue; + } + const rawValue = inlineValue ?? args[++index]; + if (rawValue === undefined) { + throw new Error(`missing value for --${name}`); + } + if (kind === 'number') { + const value = Number(rawValue); + if (!Number.isFinite(value)) { + throw new Error(`invalid number for --${name}: ${rawValue}`); + } + flags[name] = value; + continue; + } + if (kind === 'repeat') { + const existing = flags[name]; + flags[name] = [...(Array.isArray(existing) ? existing : []), rawValue]; + continue; + } + flags[name] = rawValue; + } + + return {flags, positionals}; +} + +export function stringFlag(parsed: ParsedArgs, name: string, fallback = '') { + const value = parsed.flags[name]; + return typeof value === 'string' ? value : fallback; +} + +export function boolFlag(parsed: ParsedArgs, name: string, fallback = false) { + const value = parsed.flags[name]; + return typeof value === 'boolean' ? value : fallback; +} + +export function numberFlag(parsed: ParsedArgs, name: string, fallback: number) { + const value = parsed.flags[name]; + return typeof value === 'number' ? value : fallback; +} + +export function repeatFlag(parsed: ParsedArgs, name: string) { + const value = parsed.flags[name]; + return Array.isArray(value) ? value : []; +} + +export function formatTable(headers: string[], rows: Array>) { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map(row => String(row[index] ?? '').length))); + const formatRow = (row: Array) => row.map((cell, index) => String(cell ?? '').padEnd(widths[index]!)).join(' ').trimEnd(); + return [formatRow(headers), ...rows.map(formatRow)].join('\n'); +} + +export function truncate(value: string, max = 72) { + return value.length > max ? `${value.slice(0, max)}...` : value; +} + +export function maskKey(value: string) { + if (value.length <= 8) { + return '***'; + } + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +export async function writeJson(filePath: string, value: unknown, mode?: number) { + await fs.mkdir(path.dirname(filePath), {recursive: true}); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, mode === undefined ? undefined : {mode}); + if (mode !== undefined) { + await fs.chmod(filePath, mode); + } +} + +export async function readJsonMaybe(filePath: string, fallback: T): Promise { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return fallback; + } + throw error; + } +} + +export async function pathExists(filePath: string) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export function projectCredentialsPath(workdir: string) { + return path.join(path.dirname(projectPath(workdir)), 'credentials.json'); +} diff --git a/tui/src/commands/project.ts b/tui/src/commands/project.ts new file mode 100644 index 0000000..1ff8475 --- /dev/null +++ b/tui/src/commands/project.ts @@ -0,0 +1,142 @@ +import { + loadProjects, + loadUserConfig, + markProjectUsed, + providerApiKeyEnv, + resolveApiKey, + saveUserConfig, + setGlobalApiKey, + setGlobalBaseUrl, + unsetGlobalApiKey +} from '../core/config.js'; +import {providers} from '../core/providers.js'; +import {formatTable, maskKey, parseArgs, resolveProjectWorkdir, stringFlag} from './common.js'; + +export async function runProjectCommand(args: string[]) { + const [subcommand, ...rest] = args; + switch (subcommand) { + case 'list': + return projectList(); + case 'use': + return projectUse(rest); + case 'show': + return projectShow(rest); + case 'set-key': + return projectSetKey(rest); + case 'unset-key': + return projectUnsetKey(rest); + case 'keys': + return projectKeys(); + default: + throw new Error('usage: openmelon project '); + } +} + +async function projectList() { + const [projects, config] = await Promise.all([loadProjects(), loadUserConfig()]); + if (projects.entries.length === 0) { + console.log('No projects registered. Run `openmelon init` in a project dir.'); + return; + } + console.log(formatTable(['ID', 'NAME', 'WORKDIR', 'CURRENT'], projects.entries.map(entry => [entry.id, entry.name, entry.workdir, entry.id === config.current_project ? '*' : '']))); +} + +async function projectUse(args: string[]) { + if (args.length !== 1) { + throw new Error('usage: openmelon project use '); + } + const projects = await loadProjects(); + if (!projects.entries.some(entry => entry.id === args[0])) { + throw new Error(`project ${args[0]} is not registered`); + } + const config = await loadUserConfig(); + config.current_project = args[0]; + await saveUserConfig(config); + await markProjectUsed(args[0]!); + console.log(`Current project: ${args[0]}`); +} + +async function projectShow(args: string[]) { + const {workdir, project} = await resolveProjectWorkdir(args); + console.log(`ID: ${project.id}`); + console.log(`Name: ${project.name}`); + console.log(`Workdir: ${workdir}`); + if (project.description) { + console.log(`Description: ${project.description}`); + } + if (project.persona) { + console.log(`Persona: ${project.persona}`); + } + if (project.constraints?.length) { + console.log('Constraints:'); + for (const item of project.constraints) { + console.log(` - ${item}`); + } + } + // Model / provider / image defaults are GLOBAL, not per-project. + const config = await loadUserConfig(); + if (config.defaults && Object.keys(config.defaults).length > 0) { + console.log('Defaults (global):'); + for (const [key, value] of Object.entries(config.defaults)) { + if (value) { + console.log(` ${key}: ${value}`); + } + } + } + if (project.settings && Object.keys(project.settings).length > 0) { + console.log('Settings (project):'); + for (const [key, value] of Object.entries(project.settings)) { + if (value) { + console.log(` ${key}: ${value}`); + } + } + } + await printKeySources(); +} + +async function projectSetKey(args: string[]) { + const parsed = parseArgs(args, {'api-key': 'string', key: 'string', 'base-url': 'string'}); + const provider = parsed.positionals[0] || stringFlag(parsed, 'provider', ''); + if (!provider) { + throw new Error('usage: openmelon project set-key --api-key [--base-url ]'); + } + const key = stringFlag(parsed, 'api-key') || stringFlag(parsed, 'key') || process.env[providerApiKeyEnv(provider)] || ''; + if (!key) { + throw new Error(`set-key: pass --api-key or set ${providerApiKeyEnv(provider)}`); + } + // Keys / base_url live in GLOBAL config — there is no project-scoped store. + await setGlobalApiKey(provider, key); + const baseURL = stringFlag(parsed, 'base-url'); + if (baseURL) { + await setGlobalBaseUrl(provider, baseURL); + } + console.log(`Saved global key for ${provider}.`); +} + +async function projectUnsetKey(args: string[]) { + if (args.length !== 1) { + throw new Error('usage: openmelon project unset-key '); + } + const removed = await unsetGlobalApiKey(args[0]!); + console.log(removed ? `Removed global key for ${args[0]}.` : `No key set for ${args[0]} (nothing to remove).`); +} + +async function projectKeys() { + await printKeySources(true); +} + +async function printKeySources(includeMissing = false) { + const config = await loadUserConfig(); + const rows: string[][] = []; + for (const provider of providers.map(item => item.slug)) { + const {key, source} = await resolveApiKey(provider); + const baseURL = config.providers?.[provider]?.base_url || ''; + if (key || includeMissing) { + rows.push([provider, source === 'none' ? '(none)' : source, key ? maskKey(key) : '', baseURL]); + } + } + if (rows.length > 0) { + console.log('Credentials (global):'); + console.log(formatTable(['PROVIDER', 'SOURCE', 'KEY', 'BASE_URL'], rows)); + } +} diff --git a/tui/src/commands/registry.ts b/tui/src/commands/registry.ts new file mode 100644 index 0000000..31e2bc4 --- /dev/null +++ b/tui/src/commands/registry.ts @@ -0,0 +1,117 @@ +import {addMaterial, addRegistry, getRegistry, listRegistry, removeRegistry, type RegistryKind} from '../core/registry.js'; +import {formatTable, parseArgs, repeatFlag, resolveProjectWorkdir, stringFlag, truncate} from './common.js'; + +export async function runRegistryCommand(kind: RegistryKind, args: string[]) { + const [subcommand, ...rest] = args; + if (kind === 'material') { + switch (subcommand) { + case 'add': + return materialAdd(rest); + case 'list': + return registryList(kind); + default: + throw new Error('usage: openmelon material ...'); + } + } + switch (subcommand) { + case 'add': + return registryAdd(kind, rest); + case 'list': + return registryList(kind); + case 'show': + return registryShow(kind, rest); + case 'rm': + case 'remove': + return registryRemove(kind, rest); + default: + throw new Error(`usage: openmelon ${kind} ...`); + } +} + +async function registryAdd(kind: 'character' | 'reference', args: string[]) { + const imageFlag = kind === 'character' ? 'portrait' : 'image'; + const parsed = parseArgs(args, {name: 'string', description: 'string', [imageFlag]: 'string', tag: 'repeat', update: 'boolean'}); + const slug = parsed.positionals[0]; + if (!slug) { + throw new Error(`usage: openmelon ${kind} add [--name ...] [--description ...] [--${imageFlag} path] [--tag t]...`); + } + const {workdir} = await resolveProjectWorkdir(); + const item = await addRegistry(workdir, { + kind, + slug, + name: stringFlag(parsed, 'name'), + description: stringFlag(parsed, 'description'), + tags: repeatFlag(parsed, 'tag'), + imagePath: stringFlag(parsed, imageFlag), + imageName: imageFlag, + allowExists: Boolean(parsed.flags.update) + }); + console.log(`Added ${kind} ${item.slug}`); + if (item.images?.length) { + console.log(` images: ${item.images.join(', ')}`); + } +} + +async function registryList(kind: RegistryKind) { + const {workdir} = await resolveProjectWorkdir(); + const items = await listRegistry(workdir, kind); + if (items.length === 0) { + console.log(`No ${kind}s in this project.`); + return; + } + console.log( + formatTable( + ['SLUG', 'NAME', 'IMAGES', 'TAGS', 'DESCRIPTION'], + items.map(item => [item.slug, item.name, item.images?.length ?? 0, item.tags?.join(',') ?? '', truncate(item.description ?? '')]) + ) + ); +} + +async function registryShow(kind: RegistryKind, args: string[]) { + if (args.length !== 1) { + throw new Error(`usage: openmelon ${kind} show `); + } + const {workdir} = await resolveProjectWorkdir(); + const item = await getRegistry(workdir, kind, args[0]!); + console.log(`Kind: ${item.kind}`); + console.log(`Slug: ${item.slug}`); + console.log(`Name: ${item.name}`); + if (item.description) { + console.log(`Description: ${item.description}`); + } + if (item.tags?.length) { + console.log(`Tags: ${item.tags.join(', ')}`); + } + if (item.images?.length) { + console.log('Images:'); + for (const image of item.images) { + console.log(` ${image}`); + } + } + if (item.extra && Object.keys(item.extra).length > 0) { + console.log('Metadata:'); + for (const [key, value] of Object.entries(item.extra)) { + console.log(` ${key}: ${value}`); + } + } +} + +async function registryRemove(kind: RegistryKind, args: string[]) { + if (args.length !== 1) { + throw new Error(`usage: openmelon ${kind} rm `); + } + const {workdir} = await resolveProjectWorkdir(); + await removeRegistry(workdir, kind, args[0]!); + console.log(`Removed ${kind} ${args[0]}`); +} + +async function materialAdd(args: string[]) { + const parsed = parseArgs(args, {tag: 'repeat'}); + const sourcePath = parsed.positionals[0]; + if (!sourcePath) { + throw new Error('usage: openmelon material add [--tag t]...'); + } + const {workdir} = await resolveProjectWorkdir(); + const item = await addMaterial(workdir, sourcePath, repeatFlag(parsed, 'tag')); + console.log(`Added material ${item.slug}`); +} diff --git a/tui/src/commands/search.ts b/tui/src/commands/search.ts new file mode 100644 index 0000000..266fc12 --- /dev/null +++ b/tui/src/commands/search.ts @@ -0,0 +1,87 @@ +import {listRegistry, type RegistryItem, type RegistryKind} from '../core/registry.js'; +import {formatTable, numberFlag, parseArgs, resolveProjectWorkdir, truncate} from './common.js'; + +export async function runSearchCommand(args: string[]) { + const parsed = parseArgs(args, {limit: 'number'}); + if (parsed.positionals.length === 0) { + throw new Error('usage: openmelon search ... - supports tag:foo, kind:character, -negative, "quoted phrase"'); + } + const limit = numberFlag(parsed, 'limit', 50); + const query = parseQuery(parsed.positionals.join(' ')); + const {workdir} = await resolveProjectWorkdir(); + const items = [ + ...(await listRegistry(workdir, 'character')), + ...(await listRegistry(workdir, 'reference')), + ...(await listRegistry(workdir, 'material')) + ]; + let hits = items + .map(item => ({item, score: scoreItem(item, query)})) + .filter(hit => hit.score > 0) + .sort((a, b) => b.score - a.score || a.item.slug.localeCompare(b.item.slug)); + if (limit > 0) { + hits = hits.slice(0, limit); + } + if (hits.length === 0) { + console.log('No matches.'); + return; + } + console.log(formatTable(['SCORE', 'KIND', 'SLUG', 'NAME', 'DESCRIPTION'], hits.map(hit => [hit.score, hit.item.kind, hit.item.slug, hit.item.name, truncate(hit.item.description ?? '')]))); +} + +type Query = { + terms: string[]; + negative: string[]; + tags: string[]; + kind?: RegistryKind; +}; + +function parseQuery(value: string): Query { + const tokens = value.match(/"[^"]+"|\S+/g)?.map(token => token.replace(/^"|"$/g, '')) ?? []; + const query: Query = {terms: [], negative: [], tags: []}; + for (const token of tokens) { + if (token.startsWith('kind:')) { + const kind = token.slice(5); + if (kind === 'character' || kind === 'reference' || kind === 'material') { + query.kind = kind; + } + continue; + } + if (token.startsWith('tag:')) { + query.tags.push(token.slice(4).toLowerCase()); + continue; + } + if (token.startsWith('-') && token.length > 1) { + query.negative.push(token.slice(1).toLowerCase()); + continue; + } + query.terms.push(token.toLowerCase()); + } + return query; +} + +function scoreItem(item: RegistryItem, query: Query) { + if (query.kind && item.kind !== query.kind) { + return 0; + } + const tags = item.tags?.map(tag => tag.toLowerCase()) ?? []; + if (query.tags.some(tag => !tags.includes(tag))) { + return 0; + } + const hay = `${item.slug}\n${item.name}\n${item.description ?? ''}\n${tags.join(' ')}`.toLowerCase(); + if (query.negative.some(term => hay.includes(term))) { + return 0; + } + let score = query.tags.length * 3 + (query.kind ? 1 : 0); + for (const term of query.terms) { + if (item.slug.toLowerCase() === term) { + score += 8; + } else if (item.name.toLowerCase().includes(term)) { + score += 4; + } else if (hay.includes(term)) { + score += 2; + } else { + return 0; + } + } + return score || 1; +} diff --git a/tui/src/commands/session.ts b/tui/src/commands/session.ts new file mode 100644 index 0000000..617dfb2 --- /dev/null +++ b/tui/src/commands/session.ts @@ -0,0 +1,40 @@ +import {loadSessionEvents} from '../core/session.js'; +import {formatTable, numberFlag, parseArgs, resolveProjectWorkdir} from './common.js'; + +export async function runSessionCommand(args: string[]) { + const [subcommand, ...rest] = args; + switch (subcommand) { + case 'events': + return sessionEvents(rest); + default: + throw new Error('usage: openmelon session ...'); + } +} + +async function sessionEvents(args: string[]) { + const parsed = parseArgs(args, {n: 'number'}); + const id = parsed.positionals[0]; + if (!id) { + throw new Error('usage: openmelon session events [-n 50]'); + } + const {workdir} = await resolveProjectWorkdir(); + const events = await loadSessionEvents(workdir, id, numberFlag(parsed, 'n', 50)); + if (events.length === 0) { + console.log('No events recorded for this session.'); + return; + } + console.log( + formatTable( + ['TIME', 'TYPE', 'STEP', 'TOOL', 'SPACE', 'STATUS'], + events.map(event => [formatDate(event.at), event.type ?? '', event.step ?? '', event.tool ?? '', event.space_id ?? '', event.status ?? '']) + ) + ); +} + +function formatDate(value?: string) { + const date = value ? new Date(value) : new Date(0); + if (Number.isNaN(date.getTime())) { + return ''; + } + return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`; +} diff --git a/tui/src/commands/space.ts b/tui/src/commands/space.ts new file mode 100644 index 0000000..48345a4 --- /dev/null +++ b/tui/src/commands/space.ts @@ -0,0 +1,325 @@ +import { + activateSpace, + buildCompactionDraft, + buildContextPacket, + createEpisode, + createSpace, + listSpaces, + recordCompaction, + recordDecision, + recordJsonl, + registerAsset, + updateAssetWeight +} from '../core/space.js'; +import {boolFlag, formatTable, numberFlag, parseArgs, repeatFlag, resolveProjectWorkdir, stringFlag, truncate} from './common.js'; + +export async function runSpaceCommand(args: string[]) { + const [subcommand, ...rest] = args; + switch (subcommand) { + case 'create': + return spaceCreate(rest); + case 'activate': + return spaceActivate(rest); + case 'list': + return spaceList(); + case 'show': + return spaceShow(rest); + case 'context': + return spaceContext(rest); + case 'search': + return spaceSearch(rest); + case 'decision': + return spaceDecision(rest); + case 'feedback': + return spaceFeedback(rest); + case 'memory': + return spaceMemory(rest); + case 'promote': + return spacePromote(rest); + case 'episode': + return spaceEpisode(rest); + case 'asset': + return spaceAsset(rest); + case 'asset-weight': + return spaceAssetWeight(rest); + case 'compact': + return spaceCompact(rest); + default: + throw new Error('usage: openmelon space ...'); + } +} + +async function spaceCreate(args: string[]) { + const parsed = parseArgs(args, {name: 'string', platform: 'string', audience: 'string', description: 'string', assumptions: 'string', tag: 'repeat'}); + const id = parsed.positionals[0]; + if (!id) { + throw new Error('usage: openmelon space create [--name ...] [--description ...] [--tag t]...'); + } + const {workdir} = await resolveProjectWorkdir(); + const space = await createSpace(workdir, { + id, + name: stringFlag(parsed, 'name'), + platform: stringFlag(parsed, 'platform'), + audience: stringFlag(parsed, 'audience'), + description: stringFlag(parsed, 'description'), + assumptions: stringFlag(parsed, 'assumptions'), + tags: repeatFlag(parsed, 'tag') + }); + console.log(`Created space ${space.id}`); + console.log(` dir: ${workdir}/.openmelon/spaces/${space.id}`); +} + +async function spaceActivate(args: string[]) { + const parsed = parseArgs(args, {reason: 'string', weight: 'number'}); + if (parsed.positionals.length < 2) { + throw new Error('usage: openmelon space activate '); + } + const {workdir} = await resolveProjectWorkdir(); + const result = await activateSpace(workdir, parsed.positionals[0]!, parsed.positionals.slice(1).join(' '), stringFlag(parsed, 'reason'), numberFlag(parsed, 'weight', 1)); + console.log(`Activated space ${result.space.id} with decision ${result.decision.id}`); +} + +async function spaceList() { + const {workdir} = await resolveProjectWorkdir(); + const spaces = await listSpaces(workdir); + if (spaces.length === 0) { + console.log('No creative spaces in this project.'); + return; + } + console.log(formatTable(['ID', 'NAME', 'STATUS', 'TAGS', 'DESCRIPTION'], spaces.map(space => [String(space.id ?? ''), String(space.name ?? ''), String(space.status ?? ''), Array.isArray(space.tags) ? space.tags.join(',') : '', truncate(String(space.description ?? ''))]))); +} + +async function spaceShow(args: string[]) { + const parsed = parseArgs(args, {json: 'boolean'}); + const id = parsed.positionals[0]; + if (!id) { + throw new Error('usage: openmelon space show [--json]'); + } + const {workdir, project} = await resolveProjectWorkdir(); + const packet = await buildContextPacket(workdir, project.id, id); + if (boolFlag(parsed, 'json')) { + console.log(JSON.stringify(packet, null, 2)); + return; + } + const space = packet.space as Record; + console.log(`ID: ${space.id ?? id}`); + console.log(`Name: ${space.name ?? ''}`); + if (space.platform) { + console.log(`Platform: ${space.platform}`); + } + if (space.audience) { + console.log(`Audience: ${space.audience}`); + } + if (space.description) { + console.log(`Description: ${space.description}`); + } + if (Array.isArray(space.tags) && space.tags.length > 0) { + console.log(`Tags: ${space.tags.join(', ')}`); + } + if (String(packet.assumptions ?? '').trim()) { + console.log(`\nAssumptions:\n${String(packet.assumptions).trim()}`); + } + if (String(packet.canon ?? '').trim()) { + console.log(`\nCanon:\n${String(packet.canon).trim()}`); + } + console.log(`\nRecent: ${packet.recent_decisions.length} decisions, ${packet.recent_feedback.length} feedback items, ${packet.recent_episodes.length} episodes, ${packet.assets.length} assets`); +} + +async function spaceContext(args: string[]) { + const parsed = parseArgs(args, {query: 'string', 'max-decisions': 'number', 'max-feedback': 'number', 'max-episodes': 'number', 'max-assets': 'number'}); + const id = parsed.positionals[0]; + if (!id) { + throw new Error('usage: openmelon space context [--query ...] [--max-assets n] [--max-decisions n]'); + } + const {workdir, project} = await resolveProjectWorkdir(); + const packet = await buildContextPacket(workdir, project.id, id, { + query: stringFlag(parsed, 'query'), + maxDecisions: numberFlag(parsed, 'max-decisions', 8), + maxFeedback: numberFlag(parsed, 'max-feedback', 8), + maxEpisodes: numberFlag(parsed, 'max-episodes', 8), + maxAssets: numberFlag(parsed, 'max-assets', 20) + }); + console.log(JSON.stringify(packet, null, 2)); +} + +async function spaceSearch(args: string[]) { + if (args.length === 0) { + throw new Error('usage: openmelon space search ...'); + } + const query = args.join(' ').toLowerCase(); + const terms = query.split(/\s+/).filter(Boolean); + const {workdir} = await resolveProjectWorkdir(); + const hits = (await listSpaces(workdir)) + .map(space => ({space, score: scoreSpace(space, terms)})) + .filter(hit => hit.score >= 0) + .sort((a, b) => b.score - a.score || String(a.space.id ?? '').localeCompare(String(b.space.id ?? ''))); + if (hits.length === 0) { + console.log('No matches.'); + return; + } + console.log(formatTable(['SCORE', 'ID', 'NAME', 'DESCRIPTION'], hits.map(hit => [hit.score, String(hit.space.id ?? ''), String(hit.space.name ?? ''), truncate(String(hit.space.description ?? ''))]))); +} + +async function spaceDecision(args: string[]) { + const parsed = parseArgs(args, {scope: 'string', target: 'string', reason: 'string', weight: 'number'}); + if (parsed.positionals.length < 2) { + throw new Error('usage: openmelon space decision '); + } + const {workdir} = await resolveProjectWorkdir(); + const decision = await recordDecision(workdir, parsed.positionals[0]!, { + scope: stringFlag(parsed, 'scope', 'space'), + target: stringFlag(parsed, 'target'), + decision: parsed.positionals.slice(1).join(' '), + reason: stringFlag(parsed, 'reason'), + weight: numberFlag(parsed, 'weight', 1) + }); + console.log(`Recorded decision ${decision.id}`); +} + +async function spaceFeedback(args: string[]) { + const parsed = parseArgs(args, {episode: 'string', source: 'string', evidence: 'string', recommendation: 'string'}); + if (parsed.positionals.length < 2) { + throw new Error('usage: openmelon space feedback [--evidence ...] [--recommendation ...]'); + } + const {workdir} = await resolveProjectWorkdir(); + const feedback = await recordJsonl( + workdir, + parsed.positionals[0]!, + 'feedback.jsonl', + {episode_id: stringFlag(parsed, 'episode'), source: stringFlag(parsed, 'source', 'user'), signal: parsed.positionals[1], evidence: stringFlag(parsed, 'evidence'), recommendation: stringFlag(parsed, 'recommendation')}, + 'fb' + ); + console.log(`Recorded feedback ${feedback.id}`); +} + +async function spaceMemory(args: string[]) { + const parsed = parseArgs(args, {id: 'string', kind: 'string', scope: 'string', target: 'string', source: 'string', weight: 'number', status: 'string'}); + if (parsed.positionals.length < 2) { + throw new Error('usage: openmelon space memory [--id mem-x] [--kind observation]'); + } + const {workdir} = await resolveProjectWorkdir(); + const item = await recordJsonl( + workdir, + parsed.positionals[0]!, + 'memory.jsonl', + { + id: stringFlag(parsed, 'id') || undefined, + kind: stringFlag(parsed, 'kind', 'observation'), + scope: stringFlag(parsed, 'scope'), + target: stringFlag(parsed, 'target'), + content: parsed.positionals.slice(1).join(' '), + source: stringFlag(parsed, 'source', 'user'), + weight: numberFlag(parsed, 'weight', 0.5), + status: stringFlag(parsed, 'status', 'provisional'), + updated_at: new Date().toISOString() + }, + 'mem' + ); + console.log(`Recorded memory item ${item.id}`); +} + +async function spacePromote(args: string[]) { + const parsed = parseArgs(args, {reason: 'string', target: 'string'}); + if (parsed.positionals.length < 3) { + throw new Error('usage: openmelon space promote '); + } + const {workdir} = await resolveProjectWorkdir(); + const decision = await recordDecision(workdir, parsed.positionals[0]!, { + scope: 'memory', + target: stringFlag(parsed, 'target') || parsed.positionals[1], + decision: parsed.positionals.slice(2).join(' '), + reason: stringFlag(parsed, 'reason') || `Promoted from memory item ${parsed.positionals[1]}`, + weight: 1 + }); + console.log(`Promoted memory into decision ${decision.id}`); +} + +async function spaceEpisode(args: string[]) { + const parsed = parseArgs(args, {id: 'string', title: 'string', status: 'string', brief: 'string'}); + if (parsed.positionals.length < 2) { + throw new Error('usage: openmelon space episode [--id ...] [--brief ...]'); + } + const {workdir} = await resolveProjectWorkdir(); + const episode = await createEpisode(workdir, parsed.positionals[0]!, { + id: stringFlag(parsed, 'id'), + title: stringFlag(parsed, 'title'), + status: stringFlag(parsed, 'status', 'draft'), + brief: stringFlag(parsed, 'brief'), + topic: parsed.positionals.slice(1).join(' ') + }); + console.log(`Created episode ${episode.id}`); +} + +async function spaceAsset(args: string[]) { + const parsed = parseArgs(args, {id: 'string', kind: 'string', status: 'string', reuse: 'string', weight: 'number', file: 'repeat', tag: 'repeat'}); + if (parsed.positionals.length < 2) { + throw new Error('usage: openmelon space asset [--kind ...] [--file path]...'); + } + const {workdir} = await resolveProjectWorkdir(); + const asset = await registerAsset(workdir, parsed.positionals[0]!, { + id: stringFlag(parsed, 'id'), + kind: stringFlag(parsed, 'kind'), + status: stringFlag(parsed, 'status', 'active'), + reuse: stringFlag(parsed, 'reuse'), + weight: numberFlag(parsed, 'weight', 1), + files: repeatFlag(parsed, 'file'), + tags: repeatFlag(parsed, 'tag'), + description: parsed.positionals.slice(1).join(' ') + }); + console.log(`Registered asset ${asset.id}`); +} + +async function spaceAssetWeight(args: string[]) { + const parsed = parseArgs(args, {status: 'string'}); + if (parsed.positionals.length !== 3) { + throw new Error('usage: openmelon space asset-weight [--status archived]'); + } + const weight = Number(parsed.positionals[2]); + if (!Number.isFinite(weight)) { + throw new Error(`asset-weight: invalid weight ${parsed.positionals[2]}`); + } + const {workdir} = await resolveProjectWorkdir(); + const asset = await updateAssetWeight(workdir, parsed.positionals[0]!, parsed.positionals[1]!, weight, stringFlag(parsed, 'status')); + console.log(`Updated asset ${asset.id} weight to ${Number(asset.weight ?? weight).toFixed(2)}${asset.status ? ` (${asset.status})` : ''}`); +} + +async function spaceCompact(args: string[]) { + const parsed = parseArgs(args, {draft: 'boolean', summary: 'string', scope: 'string'}); + const id = parsed.positionals[0]; + if (!id) { + throw new Error('usage: openmelon space compact [--draft | --summary ...]'); + } + const {workdir} = await resolveProjectWorkdir(); + const summary = stringFlag(parsed, 'summary'); + if (boolFlag(parsed, 'draft') || !summary.trim()) { + const body = await buildCompactionDraft(workdir, id); + console.log(body); + if (boolFlag(parsed, 'draft')) { + return; + } + throw new Error('space compact: pass --summary to record a compaction, or --draft to only print the draft'); + } + const compaction = await recordCompaction(workdir, id, summary, stringFlag(parsed, 'scope', 'space')); + console.log(`Recorded compaction ${compaction.id}`); +} + +function scoreSpace(space: Record, terms: string[]) { + if (terms.length === 0) { + return 1; + } + const hay = [space.id, space.name, space.description, space.platform, space.audience, ...(Array.isArray(space.tags) ? space.tags : [])].join('\n').toLowerCase(); + let score = 0; + for (const term of terms) { + if (String(space.id).toLowerCase() === term) { + score += 10; + } else if (hay.includes(term)) { + score += 2; + } else { + return -1; + } + } + if (space.status === 'active') { + score += 3; + } + return score; +} diff --git a/tui/src/components/PromptInput.tsx b/tui/src/components/PromptInput.tsx index 2b78b18..5614018 100644 --- a/tui/src/components/PromptInput.tsx +++ b/tui/src/components/PromptInput.tsx @@ -2,32 +2,45 @@ import React from 'react'; import {Box, Text} from 'ink'; import stringWidth from 'string-width'; import {accentColor} from '../theme.js'; -import {setCursorAnchor} from '../terminal/cursorAnchor.js'; -import {wrapBlock} from '../terminal/wrap.js'; +import {markCursorAnchor} from '../terminal/cursorAnchor.js'; +import {wrapInput} from '../state/inputEditor.js'; type Props = { input: string; + cursor: number; placeholder: string; width: number; }; -export function PromptInput({input, placeholder, width}: Props) { +export function PromptInput({input, cursor, placeholder, width}: Props) { const prompt = '› '; const rightBuffer = 6; - const available = Math.max(12, width - stringWidth(prompt) - rightBuffer); - const lines = input.length === 0 ? [''] : wrapBlock(input, available); - const lastLine = lines.at(-1) ?? ''; - setCursorAnchor({ - column: stringWidth(prompt) + stringWidth(lastLine), - rowsToBottom: 2 - }); + const available = Math.max(12, width - 2 - rightBuffer); + const lines = wrapInput(input, available); + const cursorLineIndex = Math.max(0, lines.findIndex(line => cursor >= line.start && cursor <= line.end)); + const cursorLine = lines[cursorLineIndex] ?? lines.at(-1)!; + const cursorColumn = prompt.length + stringWidth(Array.from(input).slice(cursorLine.start, Math.min(cursor, cursorLine.end)).join('')); + // Ink leaves the terminal cursor after the final status line boundary, so + // the input anchor has to move up past that boundary and the status line. + const rowFromBottom = lines.length - cursorLineIndex + 1; + const anchor = {column: cursorColumn, rowFromBottom}; return ( - + {lines.map((line, index) => ( {index === 0 ? prompt : ' '} - {input.length === 0 ? ` ${placeholder}` : line} + {input.length === 0 ? ( + + {index === cursorLineIndex ? markCursorAnchor(anchor) : ''} + {` ${placeholder}`} + + ) : ( + + {line.text} + {index === cursorLineIndex ? markCursorAnchor(anchor) : ''} + + )} ))} diff --git a/tui/src/components/SelectorPanel.tsx b/tui/src/components/SelectorPanel.tsx index 97d9873..76e49d4 100644 --- a/tui/src/components/SelectorPanel.tsx +++ b/tui/src/components/SelectorPanel.tsx @@ -16,16 +16,17 @@ type Props = { rows: SelectorRow[]; active: number; footer?: string; + compact?: boolean; }; -export function SelectorPanel({title, description, rows, active, footer}: Props) { +export function SelectorPanel({title, description, rows, active, footer, compact = false}: Props) { return ( {title} {description && {description}} {rows.map((row, index) => ( - + {index === active ? '› ' : ' '} {index + 1}. {row.title} diff --git a/tui/src/components/SlashPalette.tsx b/tui/src/components/SlashPalette.tsx index e33588f..f104990 100644 --- a/tui/src/components/SlashPalette.tsx +++ b/tui/src/components/SlashPalette.tsx @@ -6,27 +6,35 @@ import {accentColor} from '../theme.js'; type Props = { commands: SlashCommand[]; active: number; + visibleRows?: number; }; -export function SlashPalette({commands, active}: Props) { +export function SlashPalette({commands, active, visibleRows = 8}: Props) { if (commands.length === 0) { return null; } + const total = commands.length; + const limit = Math.max(1, Math.min(visibleRows, total)); + const safeActive = Math.max(0, Math.min(active, total - 1)); + const start = Math.min(Math.max(0, safeActive - limit + 1), Math.max(0, total - limit)); + const visible = commands.slice(start, start + limit); return ( - {commands.slice(0, 8).map((command, index) => ( - - - {index === active ? '› ' : ' '} - {command.name.padEnd(14)} - - {command.help} - - ))} + {visible.map((command, index) => { + const commandIndex = start + index; + const isActive = commandIndex === safeActive; + return ( + + + {isActive ? '› ' : ' '} + {command.name.padEnd(14)} + + {command.help} + + ); + })} + {total > limit && {safeActive + 1}/{total}} ); } diff --git a/tui/src/components/StatusLine.tsx b/tui/src/components/StatusLine.tsx index 972cc33..0cf7fbc 100644 --- a/tui/src/components/StatusLine.tsx +++ b/tui/src/components/StatusLine.tsx @@ -10,13 +10,12 @@ type Props = { export function StatusLine({state, width}: Props) { const tokens = - state.promptTokens > 0 || state.completionTokens > 0 - ? ` · ${formatTokenCount(state.promptTokens)} in / ${formatTokenCount(state.completionTokens)} out` + state.totalPromptTokens > 0 || state.totalCompletionTokens > 0 + ? ` · ${formatTokenCount(state.totalPromptTokens)} in / ${formatTokenCount(state.totalCompletionTokens)} out` : ''; const pending = state.pendingInputs.length > 0 ? ` · ${state.pendingInputs.length} pending` : ''; - const left = `${state.activity} · ${state.model} · ${state.reasoning} · ${state.project}${tokens}${pending}`; - const right = - state.notice || 'Tip: use vbox-cli to publish generated posts and let global audiences see your ideas.'; + const left = `${state.model}${state.reasoning ? ` ${state.reasoning}` : ''} · ${state.project}${tokens}${pending}`; + const right = state.notice || 'vbox-cli can publish generated posts'; const line = formatStatusLine(left, right, width); return ( @@ -33,7 +32,7 @@ export function StatusLine({state, width}: Props) { function formatStatusLine(left: string, right: string, width: number) { const columns = Math.max(24, width); - const rightMax = Math.max(0, Math.min(right.length, Math.floor(columns * 0.45))); + const rightMax = Math.max(0, Math.min(right.length, Math.floor(columns * 0.36))); const trimmedRight = truncateStart(right, rightMax); const leftMax = Math.max(0, columns - trimmedRight.length); const trimmedLeft = truncateEnd(left, leftMax); diff --git a/tui/src/components/Transcript.tsx b/tui/src/components/Transcript.tsx index ab575b6..e894a8e 100644 --- a/tui/src/components/Transcript.tsx +++ b/tui/src/components/Transcript.tsx @@ -2,35 +2,41 @@ import React from 'react'; import {Box, Text} from 'ink'; import type {TranscriptItem} from '../state/types.js'; import {accentColor} from '../theme.js'; +import {renderMarkdownLines, renderMarkdownPlain} from '../terminal/markdown.js'; import {wrapBlock} from '../terminal/wrap.js'; type Props = { items: TranscriptItem[]; width: number; + maxRenderedLines?: number; }; -export function Transcript({items, width}: Props) { +export function Transcript({items, width, maxRenderedLines}: Props) { return ( {items.map(item => ( - + ))} ); } -function TranscriptBlock({item, width}: {item: TranscriptItem; width: number}) { +function TranscriptBlock({item, width, maxRenderedLines}: {item: TranscriptItem; width: number; maxRenderedLines?: number}) { + if (item.markdown) { + return ; + } const color = colorForKind(item.kind); - const prefixWidth = item.kind === 'user' || item.kind === 'error' ? 2 : 0; - const rightBuffer = 4; - const lines = wrapBlock(item.text, width - prefixWidth - rightBuffer); - const marginBottom = item.kind === 'tool' || item.kind === 'result' ? 1 : 0; + const prefixWidth = gutterWidth(item.kind); + const rightBuffer = 8; + const lines = clampLines(wrapBlock(cleanTextForKind(item.kind, item.text), width - prefixWidth - rightBuffer), maxRenderedLines); + const marginBottom = marginBottomForKind(item.kind); + const bold = item.kind === 'tool'; return ( {lines.map((line, index) => ( - - {prefixForKind(item.kind, index)} + + {gutterForKind(item.kind, index)} {line} ))} @@ -38,25 +44,94 @@ function TranscriptBlock({item, width}: {item: TranscriptItem; width: number}) { ); } -function prefixForKind(kind: TranscriptItem['kind'], index: number) { +function MarkdownBlock({item, width, maxRenderedLines}: {item: TranscriptItem; width: number; maxRenderedLines?: number}) { + const rightBuffer = 8; + const rendered = renderMarkdownLines(item.text); + const lines = rendered.flatMap((line, lineIndex) => + wrapBlock(line.text, width - gutterWidth(item.kind) - rightBuffer).map((wrapped, wrappedIndex) => ({ + key: `${item.id}-${lineIndex}-${wrappedIndex}`, + text: wrapped, + color: line.color, + bold: line.bold, + gutterIndex: lineIndex === 0 && wrappedIndex === 0 ? 0 : 1 + })) + ); + const visibleLines = clampLines(lines, maxRenderedLines); + return ( + + {visibleLines.map(line => ( + + {gutterForKind(item.kind, line.gutterIndex)} + {line.text} + + ))} + + ); +} + +export function transcriptItemPlainText(item: TranscriptItem) { + return item.markdown ? renderMarkdownPlain(item.text) : item.text; +} + +function gutterForKind(kind: TranscriptItem['kind'], index: number) { if (kind === 'user') { - return index === 0 ? '› ' : ' '; + return index === 0 ? '› ' : ' '; } if (kind === 'error') { - return index === 0 ? '! ' : ' '; + return index === 0 ? '! ' : ' '; + } + if (kind === 'tool') { + return index === 0 ? '● ' : ' '; + } + if (kind === 'result') { + return index === 0 ? ' ' : ' '; + } + if (kind === 'info') { + return ' '; + } + if (kind === 'assistant') { + return ' '; } return ''; } +function gutterWidth(kind: TranscriptItem['kind']) { + switch (kind) { + case 'user': + case 'error': + case 'tool': + case 'info': + case 'result': + case 'assistant': + return 3; + default: + return 0; + } +} + +function marginBottomForKind(kind: TranscriptItem['kind']) { + switch (kind) { + case 'user': + case 'assistant': + return 1; + case 'tool': + return 1; + case 'result': + return 1; + default: + return 0; + } +} + function colorForKind(kind: TranscriptItem['kind']) { switch (kind) { case 'user': return accentColor; case 'tool': - return 'green'; + return 'cyan'; case 'result': case 'info': - return 'gray'; + return 'white'; case 'error': return 'red'; case 'assistant': @@ -64,3 +139,24 @@ function colorForKind(kind: TranscriptItem['kind']) { return 'white'; } } + +function isSoftKind(kind: TranscriptItem['kind']) { + return kind === 'result' || kind === 'info'; +} + +function cleanTextForKind(kind: TranscriptItem['kind'], text: string) { + if (kind === 'tool') { + return text.replace(/^\s*[●•]\s+/, ''); + } + if (kind === 'result') { + return text.replace(/^\s*(?:└|L)\s*/, ''); + } + return text; +} + +function clampLines(lines: T[], limit?: number) { + if (!limit || lines.length <= limit) { + return lines; + } + return lines.slice(-limit); +} diff --git a/tui/src/components/WorkingLine.tsx b/tui/src/components/WorkingLine.tsx new file mode 100644 index 0000000..f490a35 --- /dev/null +++ b/tui/src/components/WorkingLine.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import {Box, Text} from 'ink'; +import type {TuiState} from '../state/types.js'; +import {wrapBlock} from '../terminal/wrap.js'; + +type Props = { + state: TuiState; +}; + +export function WorkingLine({state}: Props) { + if (state.status !== 'thinking' && state.status !== 'tool') { + return null; + } + + const elapsed = state.runStartedAt ? ` ${formatElapsed(Date.now() - state.runStartedAt)}` : ''; + const label = state.status === 'tool' && state.activity ? state.activity : 'Working'; + + return ( + + {`● ${label}${elapsed ? ` (${elapsed} · esc to interrupt)` : ' (esc to interrupt)'}`} + {state.pendingInputs.length > 0 && ( + + {`◌ queued (${state.pendingInputs.length}) · ↑ to edit`} + {state.pendingInputs.map((input, inputIndex) => + wrapBlock(input, 96).map((line, lineIndex) => ( + + {` ${line}`} + + )) + )} + + )} + + ); +} + +function formatElapsed(ms: number) { + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 60) { + return `${seconds}s`; + } + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} diff --git a/tui/src/core/approvals.test.ts b/tui/src/core/approvals.test.ts new file mode 100644 index 0000000..0378fc7 --- /dev/null +++ b/tui/src/core/approvals.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, readFile} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {findProjectApproval, recordProjectApproval} from './approvals.js'; + +test('project approvals persist and match by tool and binary', async () => { + const workdir = await mkdtemp(path.join(tmpdir(), 'openmelon-approvals-')); + const request = {tool: 'bash', binary: 'python3', command: 'python3 script.py', description: 'test'}; + + assert.equal(await findProjectApproval(workdir, request), null); + await recordProjectApproval(workdir, request); + + const rule = await findProjectApproval(workdir, request); + assert.equal(rule?.tool, 'bash'); + assert.equal(rule?.binary, 'python3'); + + const store = JSON.parse(await readFile(path.join(workdir, '.openmelon', 'approvals.json'), 'utf8')) as {rules: unknown[]}; + assert.equal(store.rules.length, 1); +}); + +test('project approvals distinguish web search provider and fetch host', async () => { + const workdir = await mkdtemp(path.join(tmpdir(), 'openmelon-web-approvals-')); + await recordProjectApproval(workdir, { + tool: 'web_search', + binary: 'duckduckgo.com', + command: 'openmelon', + description: 'search' + }); + await recordProjectApproval(workdir, { + tool: 'web_fetch', + binary: 'example.com', + command: 'https://example.com/article', + description: 'fetch' + }); + + assert.equal((await findProjectApproval(workdir, {tool: 'web_search', binary: 'duckduckgo.com', command: 'x', description: 'search'}))?.tool, 'web_search'); + assert.equal((await findProjectApproval(workdir, {tool: 'web_fetch', binary: 'example.com', command: 'https://example.com/other', description: 'fetch'}))?.tool, 'web_fetch'); + assert.equal(await findProjectApproval(workdir, {tool: 'web_fetch', binary: 'other.test', command: 'https://other.test', description: 'fetch'}), null); +}); diff --git a/tui/src/core/approvals.ts b/tui/src/core/approvals.ts new file mode 100644 index 0000000..6968eab --- /dev/null +++ b/tui/src/core/approvals.ts @@ -0,0 +1,72 @@ +import path from 'node:path'; +import {readJsonFile, writeJsonFile} from './fs.js'; +import {stateDir} from './project.js'; + +export type ApprovalRequest = { + tool: string; + binary: string; + command: string; + description: string; +}; + +export type ApprovalRule = { + id: string; + scope: 'project'; + effect: 'allow'; + tool: string; + binary: string; + created_at: string; + last_used_at?: string; +}; + +type ApprovalStore = { + version: 1; + rules: ApprovalRule[]; +}; + +export async function findProjectApproval(workdir: string, request: ApprovalRequest) { + const store = await loadApprovalStore(workdir); + const rule = store.rules.find(item => item.effect === 'allow' && item.tool === request.tool && item.binary === request.binary); + if (!rule) { + return null; + } + rule.last_used_at = new Date().toISOString(); + await saveApprovalStore(workdir, store); + return rule; +} + +export async function recordProjectApproval(workdir: string, request: ApprovalRequest) { + const store = await loadApprovalStore(workdir); + const existing = store.rules.find(item => item.effect === 'allow' && item.tool === request.tool && item.binary === request.binary); + const now = new Date().toISOString(); + if (existing) { + existing.last_used_at = now; + await saveApprovalStore(workdir, store); + return existing; + } + const rule: ApprovalRule = { + id: `${request.tool}:${request.binary}:${Date.now()}`, + scope: 'project', + effect: 'allow', + tool: request.tool, + binary: request.binary, + created_at: now, + last_used_at: now + }; + store.rules.push(rule); + await saveApprovalStore(workdir, store); + return rule; +} + +async function loadApprovalStore(workdir: string): Promise { + return readJsonFile(approvalPath(workdir), {version: 1, rules: []}); +} + +async function saveApprovalStore(workdir: string, store: ApprovalStore) { + store.rules.sort((a, b) => `${a.tool}:${a.binary}`.localeCompare(`${b.tool}:${b.binary}`)); + await writeJsonFile(approvalPath(workdir), store); +} + +function approvalPath(workdir: string) { + return path.join(stateDir(workdir), 'approvals.json'); +} diff --git a/tui/src/core/bootstrap.ts b/tui/src/core/bootstrap.ts index e22815c..4825f8c 100644 --- a/tui/src/core/bootstrap.ts +++ b/tui/src/core/bootstrap.ts @@ -1,5 +1,5 @@ import {discoverProject, loadProject, type ProjectConfig} from './project.js'; -import {isTrusted, loadCredentials, loadUserConfig, providerApiKeyEnv} from './config.js'; +import {isTrusted, loadUserConfig, resolveApiKey} from './config.js'; export type BootstrapState = { cwd: string; @@ -9,6 +9,8 @@ export type BootstrapState = { model: string; reasoning: string; provider: string; + imageModel: string; + imageProvider: string; project?: ProjectConfig; ready: boolean; issues: string[]; @@ -21,19 +23,22 @@ export async function inspectBootstrap(): Promise { const cwd = process.cwd(); const workdir = await discoverProject(); const config = await loadUserConfig(); - const credentials = await loadCredentials(); const issues: string[] = []; - const globallyConfiguredKey = Object.keys(credentials.api_keys ?? {}).length > 0; + + // Model / provider / image model are GLOBAL only (~/.openmelon/config.json). + const provider = config.defaults?.llm_provider || 'openai'; + const model = config.defaults?.llm_model || 'gpt-5.5'; + const imageProvider = config.defaults?.image_provider || ''; + const imageModel = config.defaults?.image_model || ''; + const {key: resolvedKey} = await resolveApiKey(provider); + const hasKey = Boolean(resolvedKey); if (!workdir) { - const needsTrust = !isTrusted(config, cwd); - const provider = config.defaults?.llm_provider || 'openrouter'; - const model = config.defaults?.llm_model || 'openai/gpt-5.5'; - const reasoning = config.defaults?.reasoning_effort || 'xhigh'; + const needsTrust = !(await isTrusted(config, cwd)); if (needsTrust) { issues.push(`trust ${cwd} before OpenMelon reads project files`); } - if (!globallyConfiguredKey) { + if (!hasKey) { issues.push('no API key configured'); } issues.push('no openmelon project found'); @@ -43,38 +48,31 @@ export async function inspectBootstrap(): Promise { projectId: '', projectName: '', model, - reasoning, + reasoning: config.defaults?.reasoning_effort || 'xhigh', provider, + imageModel, + imageProvider, project: undefined, ready: false, issues, needsTrust, needsProject: true, - needsKey: !globallyConfiguredKey + needsKey: !hasKey }; } const project = await loadProject(workdir); let needsTrust = false; - if (!isTrusted(config, cwd)) { + if (!(await isTrusted(config, cwd))) { needsTrust = true; issues.push(`trust ${cwd} before OpenMelon reads project files`); } - const provider = project.defaults?.llm_provider || config.defaults?.llm_provider || 'openai'; - const model = project.defaults?.llm_model || config.defaults?.llm_model || 'gpt-5.5'; + // reasoning_effort stays a per-project behaviour knob; everything else global. const reasoning = project.settings?.reasoning_effort || config.defaults?.reasoning_effort || 'xhigh'; - const projectProvider = project.providers?.[provider]; - const globalProvider = config.providers?.[provider]; - const hasKey = - globallyConfiguredKey || - Boolean(projectProvider?.api_key) || - Boolean(globalProvider?.api_key) || - Boolean(credentials.api_keys?.[provider]) || - Boolean(process.env[providerApiKeyEnv(provider)]); if (!hasKey && provider !== 'auto') { - issues.push(`no API key for ${provider} - run \`openmelon setup\` or configure ${providerApiKeyEnv(provider)}`); + issues.push(`no API key for ${provider} - run \`openmelon setup\` or configure the provider's API key env var`); } return { @@ -85,6 +83,8 @@ export async function inspectBootstrap(): Promise { model, reasoning, provider, + imageModel, + imageProvider, project, ready: issues.length === 0, issues, diff --git a/tui/src/core/config.test.ts b/tui/src/core/config.test.ts new file mode 100644 index 0000000..c01db2d --- /dev/null +++ b/tui/src/core/config.test.ts @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, symlink} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {isTrusted, type UserConfig} from './config.js'; + +test('trust accepts symlink-equivalent project paths', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'openmelon-trust-')); + const real = path.join(root, 'real'); + const link = path.join(root, 'link'); + await mkdir(real); + await symlink(real, link); + + const config: UserConfig = {trusted_dirs: [link]}; + assert.equal(await isTrusted(config, real), true); + assert.equal(await isTrusted(config, path.join(real, 'child')), true); +}); + +test('trust does not accept prefix-only sibling paths', async () => { + const config: UserConfig = {trusted_dirs: ['/work/bigone']}; + assert.equal(await isTrusted(config, '/work/bigone-other'), false); +}); diff --git a/tui/src/core/config.ts b/tui/src/core/config.ts index 425247b..f6fbdc2 100644 --- a/tui/src/core/config.ts +++ b/tui/src/core/config.ts @@ -41,6 +41,15 @@ export async function saveUserConfig(config: UserConfig) { await writeJsonFile(path.join(openmelonHome(), 'config.json'), config); } +export async function markProjectUsed(id: string) { + const projects = await loadProjects(); + const entry = projects.entries.find(item => item.id === id); + if (entry) { + entry.last_used_at = new Date().toISOString(); + await saveProjects(projects); + } +} + export async function loadCredentials() { return readJsonFile(path.join(openmelonHome(), 'credentials.json'), {api_keys: {}}); } @@ -60,12 +69,18 @@ export async function saveProjects(projects: ProjectsConfig) { await writeJsonFile(path.join(openmelonHome(), 'projects.json'), projects); } -export function isTrusted(config: UserConfig, candidate: string) { +export async function isTrusted(config: UserConfig, candidate: string) { const current = path.resolve(candidate); + const currentReal = await realpathBestEffort(current); for (const dir of config.trusted_dirs ?? []) { const trusted = path.resolve(dir); - const rel = path.relative(trusted, current); - if (rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`))) { + const trustedReal = await realpathBestEffort(trusted); + if ( + sameOrSubdir(trusted, current) || + sameOrSubdir(trustedReal, currentReal) || + sameOrSubdir(trusted, currentReal) || + sameOrSubdir(trustedReal, current) + ) { return true; } } @@ -75,9 +90,10 @@ export function isTrusted(config: UserConfig, candidate: string) { export async function addTrustedDir(candidate: string) { const config = await loadUserConfig(); const abs = path.resolve(candidate); + const canonical = await realpathBestEffort(abs); const trusted = config.trusted_dirs ?? []; - if (!trusted.includes(abs)) { - config.trusted_dirs = [...trusted, abs]; + if (!(await isTrusted(config, canonical))) { + config.trusted_dirs = [...trusted, canonical]; await saveUserConfig(config); } } @@ -85,7 +101,7 @@ export async function addTrustedDir(candidate: string) { export async function registerProject(id: string, name: string, workdir: string, options: {setCurrent?: boolean} = {}) { const projects = await loadProjects(); const now = new Date().toISOString(); - const abs = path.resolve(workdir); + const abs = await realpathBestEffort(path.resolve(workdir)); const existing = projects.entries.find(entry => entry.id === id); if (existing) { existing.name = name; @@ -103,6 +119,25 @@ export async function registerProject(id: string, name: string, workdir: string, await saveUserConfig(config); } +async function realpathBestEffort(candidate: string) { + try { + return await fs.realpath(candidate); + } catch { + return candidate; + } +} + +function sameOrSubdir(parent: string, child: string) { + if (!parent || !child) { + return false; + } + if (child === parent) { + return true; + } + const rel = path.relative(parent, child); + return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); +} + export function providerApiKeyEnv(provider: string) { switch (provider) { case 'anthropic': @@ -113,3 +148,92 @@ export function providerApiKeyEnv(provider: string) { return 'OPENAI_API_KEY'; } } + +export function providerBaseUrlEnv(provider: string) { + switch (provider) { + case 'anthropic': + return 'ANTHROPIC_BASE_URL'; + case 'openrouter': + return 'OPENROUTER_BASE_URL'; + default: + return 'OPENAI_BASE_URL'; + } +} + +export type KeySource = 'global' | 'env' | 'none'; + +// --------------------------------------------------------------------------- +// GLOBAL config is the single source of truth for model / provider / key / +// base_url. There is intentionally no project-level override: the project file +// only carries identity, persona, constraints, continuity, and the per-project +// `reasoning_effort` behaviour knob. Everything below resolves from +// ~/.openmelon/{config,credentials}.json and env vars ONLY — never the workdir. +// --------------------------------------------------------------------------- + +/** Resolve a provider's api key + base url from global config only: + * config.json providers[] (base_url) → credentials.json (key) → env. */ +export async function resolveProvider(provider: string): Promise<{apiKey: string; baseURL: string}> { + const config = await loadUserConfig(); + const credentials = await loadCredentials(); + const globalProvider = config.providers?.[provider]; + const apiKey = + globalProvider?.api_key || credentials.api_keys?.[provider] || process.env[providerApiKeyEnv(provider)] || ''; + const baseURL = globalProvider?.base_url || process.env[providerBaseUrlEnv(provider)] || ''; + return {apiKey, baseURL}; +} + +export async function resolveApiKey(provider: string): Promise<{key: string; source: KeySource}> { + const config = await loadUserConfig(); + const credentials = await loadCredentials(); + const globalKey = config.providers?.[provider]?.api_key || credentials.api_keys?.[provider]; + if (globalKey) { + return {key: globalKey, source: 'global'}; + } + const envKey = process.env[providerApiKeyEnv(provider)]; + if (envKey) { + return {key: envKey, source: 'env'}; + } + return {key: '', source: 'none'}; +} + +/** Patch the global default model / provider / image-model / reasoning. */ +export async function setGlobalDefaults(patch: Partial>) { + const config = await loadUserConfig(); + config.defaults = {...config.defaults, ...patch}; + await saveUserConfig(config); +} + +/** Set (or, with an empty url, clear) a provider's global base_url. */ +export async function setGlobalBaseUrl(provider: string, url: string) { + const config = await loadUserConfig(); + const providers = {...config.providers}; + const entry = {...providers[provider]}; + if (url) { + entry.base_url = url; + } else { + delete entry.base_url; + } + if (Object.keys(entry).length === 0) { + delete providers[provider]; + } else { + providers[provider] = entry; + } + config.providers = providers; + await saveUserConfig(config); +} + +export async function setGlobalApiKey(provider: string, key: string) { + const credentials = await loadCredentials(); + credentials.api_keys = {...credentials.api_keys, [provider]: key}; + await saveCredentials(credentials); +} + +export async function unsetGlobalApiKey(provider: string): Promise { + const credentials = await loadCredentials(); + if (!credentials.api_keys?.[provider]) { + return false; + } + delete credentials.api_keys[provider]; + await saveCredentials(credentials); + return true; +} diff --git a/tui/src/core/registry.ts b/tui/src/core/registry.ts new file mode 100644 index 0000000..9f76f77 --- /dev/null +++ b/tui/src/core/registry.ts @@ -0,0 +1,214 @@ +import {createHash} from 'node:crypto'; +import {promises as fs} from 'node:fs'; +import path from 'node:path'; +import {stateDir} from './project.js'; + +export type RegistryKind = 'character' | 'reference' | 'material'; + +export type RegistryItem = { + kind: RegistryKind; + slug: string; + name: string; + description?: string; + tags?: string[]; + images?: string[]; + extra?: Record; + created_at?: string; + updated_at?: string; +}; + +export type RegistryAddOptions = { + kind: RegistryKind; + slug: string; + name?: string; + description?: string; + tags?: string[]; + extra?: Record; + imagePath?: string; + imageName?: string; + allowExists?: boolean; +}; + +export async function listRegistry(workdir: string, kind: RegistryKind) { + const root = path.join(stateDir(workdir), registryDir(kind)); + const entries = await readdirDirs(root); + const items = await Promise.all(entries.map(entry => getRegistry(workdir, kind, entry.name).catch(() => null))); + return items.filter((item): item is RegistryItem => item !== null).sort((a, b) => a.slug.localeCompare(b.slug)); +} + +export async function getRegistry(workdir: string, kind: RegistryKind, slug: string): Promise { + validateSlug(slug, 'registry'); + const root = itemDir(workdir, kind, slug); + const meta = await readJson(path.join(root, `${kind}.json`)); + const {description, tags} = await readSearch(path.join(root, '.search')); + return { + ...meta, + kind, + slug, + name: meta.name || slug, + description: description || meta.description || '', + tags: tags.length > 0 ? tags : meta.tags ?? [], + images: await listImages(root) + }; +} + +export async function addRegistry(workdir: string, options: RegistryAddOptions) { + validateSlug(options.slug, 'registry'); + const root = itemDir(workdir, options.kind, options.slug); + const metaPath = path.join(root, `${options.kind}.json`); + const now = new Date().toISOString(); + let item: RegistryItem; + try { + item = await readJson(metaPath); + if (!options.allowExists) { + throw new Error(`registry: already exists: ${options.kind}/${options.slug}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + item = {kind: options.kind, slug: options.slug, name: options.name || options.slug, created_at: now}; + } + + item.name = options.name || item.name || options.slug; + item.description = options.description || item.description || ''; + item.tags = options.tags && options.tags.length > 0 ? options.tags : item.tags ?? []; + item.extra = {...item.extra, ...options.extra}; + item.updated_at = now; + + await fs.mkdir(root, {recursive: true}); + if (options.imagePath) { + await copyImageInto(root, options.imagePath, options.imageName); + } + await writeSearch(path.join(root, '.search'), item.description, item.tags); + const persisted = {...item}; + delete persisted.description; + delete persisted.tags; + delete persisted.images; + await writeJson(metaPath, persisted); + return getRegistry(workdir, options.kind, options.slug); +} + +export async function removeRegistry(workdir: string, kind: RegistryKind, slug: string) { + validateSlug(slug, 'registry'); + await fs.rm(itemDir(workdir, kind, slug), {recursive: true, force: false}); +} + +export async function addMaterial(workdir: string, sourcePath: string, tags: string[]) { + const data = await fs.readFile(sourcePath); + const hash = createHash('sha256').update(data).digest('hex'); + return addRegistry(workdir, { + kind: 'material', + slug: `m-${hash.slice(0, 16)}`, + name: `m-${hash.slice(0, 16)}`, + tags, + extra: {sha256: hash}, + imagePath: sourcePath, + imageName: 'image', + allowExists: true + }); +} + +export function registryDir(kind: RegistryKind) { + return kind === 'character' ? 'characters' : kind === 'reference' ? 'references' : 'materials'; +} + +export function validateSlug(value: string, scope = 'id') { + if (value.length < 2 || value.length > 64) { + throw new Error(`${scope}: slug ${JSON.stringify(value)} must be 2..64 chars`); + } + if (!/^[a-z][a-z0-9-]*$/.test(value)) { + throw new Error(`${scope}: slug ${JSON.stringify(value)} must be kebab-case`); + } + if (value.endsWith('-') || value.includes('--')) { + throw new Error(`${scope}: slug ${JSON.stringify(value)} must not end with '-' or contain '--'`); + } +} + +async function copyImageInto(dir: string, sourcePath: string, imageName = '') { + const ext = path.extname(sourcePath).toLowerCase(); + if (!['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext)) { + throw new Error(`registry: ${sourcePath} is not a supported image`); + } + const base = imageName || path.basename(sourcePath, ext); + let target = path.join(dir, `${base}${ext}`); + for (let index = 2; await exists(target); index++) { + target = path.join(dir, `${base}-${index}${ext}`); + } + await fs.copyFile(sourcePath, target); +} + +async function listImages(root: string) { + try { + const entries = await fs.readdir(root, {withFileTypes: true}); + return entries.filter(entry => entry.isFile() && /\.(png|jpe?g|webp|gif)$/i.test(entry.name)).map(entry => entry.name).sort(); + } catch { + return []; + } +} + +async function readSearch(filePath: string) { + let body = ''; + try { + body = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {description: '', tags: [] as string[]}; + } + throw error; + } + let description = ''; + const tags: string[] = []; + for (const raw of body.split('\n')) { + const [key, ...rest] = raw.split(':'); + const value = rest.join(':').trim(); + if (key?.trim() === 'description') { + description = value; + } + if (key?.trim() === 'tags') { + tags.push(...value.split(',').map(tag => tag.trim()).filter(Boolean)); + } + } + return {description, tags}; +} + +async function writeSearch(filePath: string, description = '', tags: string[] = []) { + const lines = [`description: ${description.trim().replace(/\s+/g, ' ')}`]; + if (tags.length > 0) { + lines.push(`tags: ${tags.join(', ')}`); + } + await fs.writeFile(filePath, `${lines.join('\n')}\n`); +} + +async function readdirDirs(root: string) { + try { + const entries = await fs.readdir(root, {withFileTypes: true}); + return entries.filter(entry => entry.isDirectory()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } +} + +async function exists(filePath: string) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readJson(filePath: string) { + return JSON.parse(await fs.readFile(filePath, 'utf8')) as T; +} + +async function writeJson(filePath: string, value: unknown) { + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function itemDir(workdir: string, kind: RegistryKind, slug: string) { + return path.join(stateDir(workdir), registryDir(kind), slug); +} diff --git a/tui/src/core/session.ts b/tui/src/core/session.ts index 8508113..a2393d4 100644 --- a/tui/src/core/session.ts +++ b/tui/src/core/session.ts @@ -66,7 +66,14 @@ export async function listSessions(workdir: string, limit = 10): Promise line.trim()) diff --git a/tui/src/core/space.ts b/tui/src/core/space.ts index a609f61..5f84961 100644 --- a/tui/src/core/space.ts +++ b/tui/src/core/space.ts @@ -1,6 +1,7 @@ import {promises as fs} from 'node:fs'; import path from 'node:path'; import {stateDir} from './project.js'; +import {validateSlug} from './registry.js'; export type SpaceMeta = { id?: string; @@ -17,6 +18,16 @@ export type SpaceSummary = { assets: number; }; +export type CreateSpaceOptions = { + id: string; + name?: string; + platform?: string; + audience?: string; + description?: string; + tags?: string[]; + assumptions?: string; +}; + type Decision = { decision?: string; target?: string; @@ -58,6 +69,162 @@ export async function summarizeSpace(workdir: string, id: string): Promise readJsonMaybe | null>(path.join(root, entry.name, 'space.json'), null))); + return spaces.filter((space): space is Record => space !== null).sort((a, b) => String(a.id ?? '').localeCompare(String(b.id ?? ''))); +} + +export async function buildContextPacket( + workdir: string, + projectId: string, + id: string, + options: {query?: string; maxDecisions?: number; maxFeedback?: number; maxEpisodes?: number; maxAssets?: number} = {} +) { + const root = path.join(stateDir(workdir), 'spaces', id); + const space = await readJsonMaybe | null>(path.join(root, 'space.json'), null); + if (!space) { + throw new Error(`continuity: not found: space ${id}`); + } + const maxDecisions = options.maxDecisions ?? 8; + const maxFeedback = options.maxFeedback ?? 8; + const maxEpisodes = options.maxEpisodes ?? 8; + const maxAssets = options.maxAssets ?? 20; + return { + project_id: projectId, + authority: 'canon and recent_decisions are confirmed/high-authority; assumptions are provisional/low-authority', + space, + selection: { + query: options.query ?? '', + decision_limit: maxDecisions, + feedback_limit: maxFeedback, + episode_limit: maxEpisodes, + asset_limit: maxAssets + }, + assumptions: await readTextMaybe(path.join(root, 'assumptions.md')), + canon: await readTextMaybe(path.join(root, 'canon.md')), + memory: await readTextMaybe(path.join(root, 'memory.md')), + plan: await readTextMaybe(path.join(root, 'plan.md')), + recent_decisions: await readJsonl(path.join(root, 'decisions.jsonl'), maxDecisions), + recent_feedback: await readJsonl(path.join(root, 'feedback.jsonl'), maxFeedback), + recent_episodes: await readChildJson>(path.join(root, 'episodes'), 'episode.json').then(items => items.slice(0, maxEpisodes)), + assets: await readChildJson>(path.join(root, 'assets'), 'asset.json').then(items => items.slice(0, maxAssets)) + }; +} + +export async function activateSpace(workdir: string, id: string, decisionText: string, reason = '', weight = 1) { + const root = path.join(stateDir(workdir), 'spaces', id); + const space = await readJsonMaybe | null>(path.join(root, 'space.json'), null); + if (!space) { + throw new Error(`continuity: not found: space ${id}`); + } + const decision = await appendDecision(root, {scope: 'space', target: 'space_activation', decision: decisionText, reason, weight}); + space.status = 'active'; + space.updated_at = new Date().toISOString(); + await writeJson(path.join(root, 'space.json'), space); + return {space, decision}; +} + +export async function recordDecision(workdir: string, spaceId: string, value: Record) { + return appendDecision(await ensureSpaceRoot(workdir, spaceId), value); +} + +export async function recordJsonl(workdir: string, spaceId: string, file: 'feedback.jsonl' | 'memory.jsonl', value: Record, prefix: string) { + const root = await ensureSpaceRoot(workdir, spaceId); + const record = {id: String(value.id ?? `${prefix}-${timestamp()}`), created_at: new Date().toISOString(), ...value}; + await appendJsonl(path.join(root, file), record); + return record; +} + +export async function createEpisode(workdir: string, spaceId: string, value: Record) { + const root = await ensureSpaceRoot(workdir, spaceId); + const id = String(value.id ?? '') || slugFromText(String(value.topic ?? value.title ?? 'episode')); + const now = new Date().toISOString(); + const episode = {id, title: String(value.title ?? ''), topic: String(value.topic ?? ''), status: String(value.status ?? 'draft'), brief: String(value.brief ?? ''), created_at: now, updated_at: now}; + const dir = path.join(root, 'episodes', id); + await fs.mkdir(dir, {recursive: true}); + await writeJson(path.join(dir, 'episode.json'), episode); + if (episode.brief) { + await fs.writeFile(path.join(dir, 'brief.md'), ensureNL(episode.brief)); + } + return episode; +} + +export async function registerAsset(workdir: string, spaceId: string, value: Record) { + const root = await ensureSpaceRoot(workdir, spaceId); + const id = String(value.id ?? '') || slugFromText(String(value.description ?? value.kind ?? 'asset')); + const now = new Date().toISOString(); + const asset = { + id, + space_id: spaceId, + kind: String(value.kind ?? ''), + status: String(value.status ?? 'active'), + description: String(value.description ?? ''), + reuse_policy: String(value.reuse_policy ?? value.reuse ?? ''), + files: Array.isArray(value.files) ? value.files.map(String) : [], + tags: Array.isArray(value.tags) ? value.tags.map(String) : [], + weight: numberArg(value.weight, 1), + created_at: now, + updated_at: now + }; + const dir = path.join(root, 'assets', id); + await fs.mkdir(dir, {recursive: true}); + await writeJson(path.join(dir, 'asset.json'), asset); + return asset; +} + +export async function updateAssetWeight(workdir: string, spaceId: string, assetId: string, weight: number, status = '') { + const root = await ensureSpaceRoot(workdir, spaceId); + const filePath = path.join(root, 'assets', assetId, 'asset.json'); + const asset = await readJsonMaybe | null>(filePath, null); + if (!asset) { + throw new Error(`asset ${assetId} not found`); + } + asset.weight = weight; + if (status) { + asset.status = status; + } + asset.updated_at = new Date().toISOString(); + await writeJson(filePath, asset); + return asset; +} + +export async function recordCompaction(workdir: string, spaceId: string, summary: string, scope = 'space') { + const root = await ensureSpaceRoot(workdir, spaceId); + const record = {id: `cmp-${timestamp()}`, summary, scope, created_at: new Date().toISOString()}; + await appendJsonl(path.join(root, 'compactions.jsonl'), record); + return record; +} + export async function buildCompactionDraft(workdir: string, id: string) { const root = path.join(stateDir(workdir), 'spaces', id); const [space, canon, decisions, feedback, episodes, assets] = await Promise.all([ @@ -89,17 +256,41 @@ export async function buildCompactionDraft(workdir: string, id: string) { return sections.join('\n\n'); } -async function readJsonMaybe(filePath: string): Promise { +async function readJsonMaybe(filePath: string, fallback?: T): Promise { try { return JSON.parse(await fs.readFile(filePath, 'utf8')) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return {} as T; + return fallback !== undefined ? fallback : ({} as T); } throw error; } } +async function writeJson(filePath: string, value: unknown) { + await fs.mkdir(path.dirname(filePath), {recursive: true}); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function appendJsonl(filePath: string, value: unknown) { + await fs.mkdir(path.dirname(filePath), {recursive: true}); + await fs.appendFile(filePath, `${JSON.stringify(value)}\n`); +} + +async function appendDecision(root: string, fields: Record) { + const record = {id: `dec-${timestamp()}`, status: 'active', created_at: new Date().toISOString(), ...fields}; + await appendJsonl(path.join(root, 'decisions.jsonl'), record); + return record; +} + +async function ensureSpaceRoot(workdir: string, spaceId: string) { + const root = path.join(stateDir(workdir), 'spaces', spaceId); + if (!(await exists(path.join(root, 'space.json')))) { + throw new Error(`continuity: not found: space ${spaceId}`); + } + return root; +} + async function readTextMaybe(filePath: string) { try { return await fs.readFile(filePath, 'utf8'); @@ -184,3 +375,58 @@ async function countDirs(dirPath: string) { throw error; } } + +async function readdirDirs(root: string) { + try { + const entries = await fs.readdir(root, {withFileTypes: true}); + return entries.filter(entry => entry.isDirectory()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } +} + +async function exists(filePath: string) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function ensureNL(value: string) { + return value.endsWith('\n') ? value : `${value}\n`; +} + +function numberArg(value: unknown, fallback: number) { + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : fallback; +} + +function slugFromText(value: string) { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9._ -]/g, '') + .replace(/[._ -]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/-+$/g, ''); + return /^[a-z]/.test(slug) && slug.length >= 2 ? slug : 'item'; +} + +function timestamp() { + const date = new Date(); + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`; +} + +function pad(value: number) { + return String(value).padStart(2, '0'); +} + +const defaultAssumptionsBody = '# Assumptions\n\nModel-generated setup assumptions live here until the user confirms, rejects, or edits them. These are lower authority than canon and decisions.\n'; +const defaultCanonBody = '# Canon\n\nConfirmed long-term rules live here. Do not infer new canon without user confirmation.\n\n## Voice\n- TBD\n\n## Visual Style\n- TBD\n\n## Episode Structure\n- TBD\n'; +const defaultMemoryBody = '# Memory\n\n'; +const defaultPlanBody = '# Plan\n\n## Backlog\n- TBD\n'; diff --git a/tui/src/main.tsx b/tui/src/main.tsx index 3878308..db4adb0 100644 --- a/tui/src/main.tsx +++ b/tui/src/main.tsx @@ -8,6 +8,40 @@ export type TuiOptions = { resumeId?: string; }; -export function runTui(options: TuiOptions = {}) { - render(, {exitOnCtrlC: false, stdout: createAnchoredStdout(process.stdout)}); +type TuiSessionInfo = { + sessionId?: string; + sessionDir?: string; +}; + +export async function runTui(options: TuiOptions = {}) { + let sessionInfo: TuiSessionInfo = {}; + const instance = render( + { + sessionInfo = {...sessionInfo, ...info}; + }} + />, + {exitOnCtrlC: false, stdout: createAnchoredStdout(process.stdout)} + ); + await instance.waitUntilExit(); + const hint = resumeHint(sessionInfo); + if (hint) { + process.stderr.write(`\n${hint}\n`); + } +} + +function resumeHint(info: TuiSessionInfo) { + if (!info.sessionId && !info.sessionDir) { + return ''; + } + const lines = []; + if (info.sessionDir) { + lines.push(`session saved at ${info.sessionDir}`); + } + if (info.sessionId) { + lines.push(`to resume: openmelon resume ${info.sessionId}`); + } + return lines.join('\n'); } diff --git a/tui/src/onboarding/Onboarding.tsx b/tui/src/onboarding/Onboarding.tsx index 60c6db6..3596012 100644 --- a/tui/src/onboarding/Onboarding.tsx +++ b/tui/src/onboarding/Onboarding.tsx @@ -706,6 +706,8 @@ const emptyBootstrap: BootstrapState = { model: '', reasoning: '', provider: 'openrouter', + imageModel: '', + imageProvider: '', project: undefined, ready: false, issues: [], diff --git a/tui/src/runtime/index.ts b/tui/src/runtime/index.ts new file mode 100644 index 0000000..52a84d2 --- /dev/null +++ b/tui/src/runtime/index.ts @@ -0,0 +1,11 @@ +import {createNativeRuntimeClient} from './nativeClient.js'; +import type {RuntimeClient, RuntimeClientOptions, RuntimeEventHandler} from './protocol.js'; + +export type {ApprovalRequest, RuntimeClient, RuntimeClientOptions, RuntimeEvent, RuntimeEventHandler, RuntimeRequest} from './protocol.js'; + +export function createRuntimeClient(emit: RuntimeEventHandler, options: RuntimeClientOptions = {}): RuntimeClient { + if (process.env.OPENMELON_RUNTIME === 'process' || process.env.OPENMELON_RUNTIME === 'go') { + emit({type: 'append', kind: 'error', text: 'Go process runtime is retired in the TS-only entrypoint; using native TypeScript runtime.'}); + } + return createNativeRuntimeClient(emit, options); +} diff --git a/tui/src/runtime/nativeClient.test.ts b/tui/src/runtime/nativeClient.test.ts new file mode 100644 index 0000000..07065fd --- /dev/null +++ b/tui/src/runtime/nativeClient.test.ts @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, readdir, readFile, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {createNativeRuntimeClient} from './nativeClient.js'; +import type {RuntimeEvent} from './protocol.js'; + +test('native runtime creates a session lazily on first run', async () => { + const workdir = await mkdtemp(path.join(tmpdir(), 'openmelon-native-client-')); + await writeProject(workdir); + + const restoreHome = await useTempGlobalConfig(); + const previousCwd = process.cwd(); + process.chdir(workdir); + try { + const events: RuntimeEvent[] = []; + const client = createNativeRuntimeClient(event => events.push(event)); + await waitFor(() => events.some(event => event.type === 'ready')); + + assert.equal(events.find(event => event.type === 'ready' && event.sessionId)?.type, undefined); + assert.deepEqual(await listSessions(workdir), []); + + client.run('hello'); + await waitFor(() => events.some(event => event.type === 'ready' && Boolean(event.sessionId))); + + assert.equal((await listSessions(workdir)).length, 1); + client.shutdown(); + } finally { + process.chdir(previousCwd); + restoreHome(); + } +}); + +test('native runtime resumes the same session instead of creating a child session', async () => { + const workdir = await mkdtemp(path.join(tmpdir(), 'openmelon-native-client-resume-')); + await writeProject(workdir); + const parent = 'parent-session'; + await mkdir(path.join(workdir, '.openmelon', 'sessions', parent), {recursive: true}); + await writeFile( + path.join(workdir, '.openmelon', 'sessions', parent, 'meta.json'), + JSON.stringify({id: parent, project_id: 'proj', started_at: new Date().toISOString(), workspace_root: workdir}) + ); + await writeFile( + path.join(workdir, '.openmelon', 'sessions', parent, 'messages.jsonl'), + `${JSON.stringify({role: 'user', content: 'old turn'})}\n` + ); + + const restoreHome = await useTempGlobalConfig(); + const previousCwd = process.cwd(); + process.chdir(workdir); + try { + const events: RuntimeEvent[] = []; + const client = createNativeRuntimeClient(event => events.push(event), {resumeId: parent}); + await waitFor(() => events.some(event => event.type === 'ready' && Boolean(event.sessionId))); + + assert.equal((events.find(event => event.type === 'ready' && Boolean(event.sessionId)) as {sessionId?: string}).sessionId, parent); + assert.deepEqual(await listSessions(workdir), [parent]); + client.shutdown(); + } finally { + process.chdir(previousCwd); + restoreHome(); + } +}); + +test('native runtime downgrades missing resume history to a fresh lazy session', async () => { + const workdir = await mkdtemp(path.join(tmpdir(), 'openmelon-native-client-bad-resume-')); + await writeProject(workdir); + const missingHistory = 'missing-history'; + await mkdir(path.join(workdir, '.openmelon', 'sessions', missingHistory), {recursive: true}); + await writeFile( + path.join(workdir, '.openmelon', 'sessions', missingHistory, 'meta.json'), + JSON.stringify({id: missingHistory, project_id: 'proj', started_at: new Date().toISOString(), workspace_root: workdir}) + ); + + const restoreHome = await useTempGlobalConfig(); + const previousCwd = process.cwd(); + process.chdir(workdir); + try { + const events: RuntimeEvent[] = []; + const client = createNativeRuntimeClient(event => events.push(event), {resumeId: missingHistory}); + await waitFor(() => events.some(event => event.type === 'ready' && Boolean(event.sessionId))); + + assert.equal((events.find(event => event.type === 'ready' && Boolean(event.sessionId)) as {sessionId?: string}).sessionId, missingHistory); + assert.deepEqual(await listSessions(workdir), [missingHistory]); + assert.equal(await readFile(path.join(workdir, '.openmelon', 'sessions', missingHistory, 'messages.jsonl'), 'utf8'), ''); + client.shutdown(); + } finally { + process.chdir(previousCwd); + restoreHome(); + } +}); + +test('clearHistory forgets the active session and next run creates a new one', async () => { + const workdir = await mkdtemp(path.join(tmpdir(), 'openmelon-native-client-clear-')); + await writeProject(workdir); + + const restoreHome = await useTempGlobalConfig(); + const previousCwd = process.cwd(); + process.chdir(workdir); + try { + const events: RuntimeEvent[] = []; + const client = createNativeRuntimeClient(event => events.push(event)); + await waitFor(() => events.some(event => event.type === 'ready')); + + client.run('first'); + await waitFor(() => events.some(event => event.type === 'ready' && Boolean(event.sessionId))); + const firstSession = (events.find(event => event.type === 'ready' && Boolean(event.sessionId)) as {sessionId?: string}).sessionId; + assert.ok(firstSession); + + client.clearHistory(); + await waitFor(() => events.some(event => event.type === 'ready' && event.clearSession)); + const clearEvent = events.find(event => event.type === 'ready' && event.clearSession) as {sessionId?: string; clearSession?: boolean}; + assert.equal(clearEvent.sessionId, ''); + + client.run('second'); + await waitFor(() => events.filter(event => event.type === 'ready' && Boolean(event.sessionId)).length >= 2); + const readyWithSessions = events.filter(event => event.type === 'ready' && Boolean(event.sessionId)) as Array<{sessionId?: string}>; + const secondSession = readyWithSessions.at(-1)?.sessionId; + assert.ok(secondSession); + assert.notEqual(secondSession, firstSession); + assert.equal((await listSessions(workdir)).length, 2); + client.shutdown(); + } finally { + process.chdir(previousCwd); + restoreHome(); + } +}); + +async function writeProject(workdir: string) { + await mkdir(path.join(workdir, '.openmelon'), {recursive: true}); + // Project file carries identity only; model / provider / key are GLOBAL now. + await writeFile( + path.join(workdir, '.openmelon', 'project.json'), + JSON.stringify({id: 'proj', name: 'Project'}) + ); +} + +// Isolate ~/.openmelon to a throwaway dir and point the global default at a dead +// local port so the runtime resolves a real provider (deterministic, fast +// connection-refused) WITHOUT touching the developer's real config or API key. +async function useTempGlobalConfig(): Promise<() => void> { + const home = await mkdtemp(path.join(tmpdir(), 'openmelon-home-')); + await writeFile( + path.join(home, 'config.json'), + JSON.stringify({ + defaults: {llm_provider: 'openai', llm_model: 'gpt-test'}, + providers: {openai: {api_key: 'test-key', base_url: 'http://127.0.0.1:9'}} + }) + ); + const previous = process.env.OPENMELON_HOME; + process.env.OPENMELON_HOME = home; + return () => { + if (previous === undefined) { + delete process.env.OPENMELON_HOME; + } else { + process.env.OPENMELON_HOME = previous; + } + }; +} + +async function listSessions(workdir: string) { + try { + return await readdir(path.join(workdir, '.openmelon', 'sessions')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } +} + +async function waitFor(predicate: () => boolean) { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error('timed out waiting for condition'); +} diff --git a/tui/src/runtime/nativeClient.ts b/tui/src/runtime/nativeClient.ts new file mode 100644 index 0000000..7f982d5 --- /dev/null +++ b/tui/src/runtime/nativeClient.ts @@ -0,0 +1,540 @@ +import path from 'node:path'; +import {findProjectApproval, recordProjectApproval} from '../core/approvals.js'; +import {outputsDir} from '../core/project.js'; +import {loadSessionHistory} from '../core/session.js'; +import {buildNativeTools} from './nativeTools.js'; +import {buildSystemPrompt, loadNativeRuntimeBootstrap} from './nativeConfig.js'; +import {streamChat} from './openaiCompat.js'; +import {appendEvent, appendMessages, appendPrompt, createNativeSession, openNativeSession, setRuntimeInfo, writeSummary, type NativeSession} from './sessionStore.js'; +import type {RuntimeClient, RuntimeClientOptions, RuntimeEventHandler} from './protocol.js'; +import type {ChatMessage, NativeRuntimeContext, NativeTool, ToolCall} from './nativeTypes.js'; + +const maxSteps = 24; +type NativeRuntimeBoot = Awaited>; + +export function createNativeRuntimeClient(emit: RuntimeEventHandler, options: RuntimeClientOptions = {}): RuntimeClient { + let closed = false; + let running = false; + let controller: AbortController | null = null; + let boot: NativeRuntimeBoot | null = null; + let context: NativeRuntimeContext | null = null; + let session: NativeSession | null = null; + let history: ChatMessage[] = []; + let persisted = 0; + let tools: NativeTool[] = []; + let systemPrompt = ''; + const pending: string[] = []; + const approvals = new Map void>(); + const alwaysApprovedScopes = new Set(); + let approvalSeq = 0; + + const ready = (async () => { + boot = await loadNativeRuntimeBootstrap(); + if (options.resumeId) { + try { + history = (await loadSessionHistory(boot.workdir, options.resumeId)) as ChatMessage[]; + persisted = history.length; + session = await openNativeSession(boot.workdir, options.resumeId); + await setRuntimeInfo(session, boot.llm.provider, boot.llm.model); + context = buildContext(boot, session); + rebuildToolsAndPrompt(); + } catch (error) { + history = []; + persisted = 0; + rebuildToolsAndPrompt(); + emit({type: 'append', kind: 'info', text: `resume ${options.resumeId} unavailable; starting a new session on first input. ${(error as Error).message}`}); + } + } else { + rebuildToolsAndPrompt(); + } + emit({ + type: 'ready', + status: 'ready', + activity: 'Ready', + ...sessionReadyInfo(), + model: boot.llm.model, + reasoning: boot.llm.reasoning, + project: boot.project.id, + provider: boot.llm.provider + }); + if (options.initialPrompt?.trim()) { + queueMicrotask(() => start(options.initialPrompt!.trim())); + } + })().catch(error => { + emit({type: 'append', kind: 'error', text: (error as Error).message}); + emit({type: 'status', status: 'error', activity: 'Runtime unavailable'}); + closed = true; + }); + + async function start(text: string) { + await ready; + if (closed) { + return; + } + if (running) { + pending.push(text); + if (session) { + await appendPrompt(session, 'pending', text); + } + return; + } + await ensureSession(text); + if (!context || !session) { + throw new Error('native runtime not initialized'); + } + running = true; + controller = new AbortController(); + await appendPrompt(session, 'user', text); + try { + const result = await runLoop(text, controller.signal); + history = result.messages; + await writeSummary(session, result.summary, result.artifacts, result.finished); + emit({type: 'status', status: 'ready', activity: 'Ready'}); + emit({type: 'done'}); + } catch (error) { + if (controller.signal.aborted || (error as Error).name === 'AbortError') { + emit({type: 'append', kind: 'error', text: 'interrupted'}); + emit({type: 'status', status: 'error', activity: 'Interrupted'}); + emit({type: 'done'}); + } else { + emit({type: 'append', kind: 'error', text: (error as Error).message}); + emit({type: 'status', status: 'error', activity: 'Error'}); + emit({type: 'done'}); + } + } finally { + running = false; + controller = null; + } + + const next = drainPending().join('\n\n').trim(); + if (next && !closed) { + void start(next); + } + } + + async function runLoop(userInput: string, signal: AbortSignal) { + if (!context || !session) { + throw new Error('native runtime not initialized'); + } + let messages = history.length > 0 ? [...history] : [{role: 'system' as const, content: systemPrompt}]; + if (userInput.trim()) { + const userMessage: ChatMessage = {role: 'user', content: userInput.trim()}; + messages.push(userMessage); + await persistMessages([userMessage]); + } + let summary = ''; + let artifacts: string[] = []; + let finished = false; + + for (let step = 1; step <= maxSteps; step++) { + const drained = drainPending(); + for (const item of drained) { + messages.push({role: 'user', content: item}); + } + emit({type: 'status', status: 'thinking', activity: `Thinking step ${step}`}); + await appendEvent(session, 'model_request', {step, status: 'before', detail: {messages: messages.length, tools: tools.length}}); + const response = await streamChat(context.llm, messages, tools.map(tool => tool.spec), signal, { + onText(delta) { + emit({type: 'append', kind: 'assistant', text: delta, delta: true, markdown: true}); + } + }); + messages.push(response.message); + await persistMessages([response.message]); + await appendEvent(session, 'model_response', { + step, + status: response.finish_reason, + detail: { + tool_calls: response.message.tool_calls?.length ?? 0, + content_chars: response.message.content?.length ?? 0, + prompt_tokens: response.usage.prompt_tokens ?? 0, + completion_tokens: response.usage.completion_tokens ?? 0 + } + }); + emit({type: 'usage', promptTokens: response.usage.prompt_tokens ?? 0, completionTokens: response.usage.completion_tokens ?? 0, totalTokens: response.usage.total_tokens ?? 0}); + + const calls = response.message.tool_calls ?? []; + if (calls.length === 0) { + finished = response.finish_reason === 'stop' || response.finish_reason === 'other'; + return {messages, summary, artifacts, finished}; + } + + for (const call of calls) { + const output = await dispatchTool(call, step, signal); + const content = JSON.stringify(output); + const toolMessage: ChatMessage = {role: 'tool', tool_call_id: call.id, content}; + messages.push(toolMessage); + await persistMessages([toolMessage]); + if (call.name === 'finish' && !isErrorResult(output)) { + const result = output as {summary?: string; artifacts?: string[]}; + summary = result.summary ?? ''; + artifacts = Array.isArray(result.artifacts) ? result.artifacts : []; + finished = true; + } + } + if (finished) { + return {messages, summary, artifacts, finished}; + } + } + throw new Error(`runtime: hit MaxSteps=${maxSteps} without finishing`); + } + + async function dispatchTool(call: ToolCall, step: number, signal: AbortSignal) { + if (!session) { + throw new Error('native runtime not initialized'); + } + const tool = tools.find(candidate => candidate.spec.name === call.name); + if (!tool) { + return {error: `unknown tool ${call.name}`}; + } + if (call.name !== 'finish') { + emit({type: 'append', kind: 'tool', text: formatToolCall(call)}); + emit({type: 'status', status: 'tool', activity: `Calling ${call.name}`}); + } + await appendEvent(session, 'tool_call', {step, tool: call.name, status: 'before', detail: {tool_call_id: call.id}}); + let result: unknown; + let status = 'ok'; + try { + result = await tool.dispatch(call.arguments, signal); + if (isErrorResult(result)) { + status = 'error'; + } + } catch (error) { + status = 'error'; + result = {error: (error as Error).message}; + } + await appendEvent(session, 'tool_result', {step, tool: call.name, status, detail: {tool_call_id: call.id, content_chars: JSON.stringify(result).length}}); + if (call.name !== 'finish') { + const kind = status === 'error' ? 'error' : 'result'; + emit({type: 'append', kind, text: compactToolResult(result)}); + } + return result; + } + + function drainPending() { + const out = pending.splice(0); + if (out.length > 0) { + emit({type: 'pending-applied', texts: out}); + } + return out; + } + + function removePending(text: string) { + const index = pending.indexOf(text); + if (index < 0) { + return; + } + pending.splice(index, 1); + } + + function approvalRequest(req: {id: string; tool: string; command: string; description: string; binary: string}) { + const key = approvalKey(req); + if (key && alwaysApprovedScopes.has(key)) { + return Promise.resolve({approved: true, always: true}); + } + if (context) { + return findProjectApproval(context.workdir, req).then(rule => { + if (rule) { + if (key) { + alwaysApprovedScopes.add(key); + } + return {approved: true, always: true}; + } + return promptApproval(req); + }); + } + return promptApproval(req); + } + + function promptApproval(req: {id: string; tool: string; command: string; description: string; binary: string}) { + const id = `approval-${++approvalSeq}`; + return new Promise<{approved: boolean; always: boolean}>(resolve => { + approvals.set(id, decision => { + const key = approvalKey(req); + if (decision.approved && decision.always && key) { + alwaysApprovedScopes.add(key); + if (context) { + void recordProjectApproval(context.workdir, req); + } + } + resolve(decision); + }); + emit({type: 'approval', activity: `Approve ${req.tool}`, detail: {...req, id}}); + }); + } + + function approvalKey(req: {tool: string; binary: string}) { + return req.tool && req.binary ? `${req.tool}:${req.binary}` : ''; + } + + async function persistMessages(messages: ChatMessage[]) { + if (!session || messages.length === 0) { + return; + } + await appendMessages(session, messages); + } + + function sessionReadyInfo() { + const current = session as NativeSession | null; + return { + sessionId: current?.id, + sessionDir: current?.dir + }; + } + + return { + isAvailable() { + return !closed; + }, + run(text: string) { + void start(text); + }, + pending(text: string) { + if (text.trim()) { + pending.push(text.trim()); + } + }, + unpending(text: string) { + removePending(text.trim()); + }, + cancel() { + controller?.abort(); + }, + clearHistory() { + history = []; + persisted = 0; + session = null; + context = null; + rebuildToolsAndPrompt(); + emit({ + type: 'ready', + status: 'ready', + activity: 'Ready', + sessionId: '', + sessionDir: '', + clearSession: true, + model: boot?.llm.model, + reasoning: boot?.llm.reasoning, + project: boot?.project.id, + provider: boot?.llm.provider + }); + emit({type: 'append', kind: 'info', text: '(conversation cleared; next message starts a new session)', transient: true}); + }, + history() { + if (history.length === 0) { + emit({type: 'append', kind: 'info', text: '(no conversation history)', transient: true}); + return; + } + emit({ + type: 'append', + kind: 'info', + text: history.map((message, index) => ` [${index}] ${message.role}: ${truncate((message.content ?? '').replace(/\s+/g, ' '), 200)}`).join('\n'), + transient: true + }); + }, + save(filePath: string) { + void (async () => { + const {promises: fs} = await import('node:fs'); + await fs.writeFile(filePath, history.map(message => JSON.stringify(message)).join('\n') + '\n'); + emit({type: 'append', kind: 'info', text: `saved ${history.length} messages -> ${filePath}`, transient: true}); + })().catch(error => emit({type: 'append', kind: 'error', text: `/save: ${(error as Error).message}`, transient: true})); + }, + reload() { + void reloadRuntime(); + }, + approval(id: string, approved: boolean, always: boolean) { + const resolve = approvals.get(id); + approvals.delete(id); + resolve?.({approved, always}); + }, + shutdown() { + closed = true; + controller?.abort(); + for (const [id, resolve] of approvals) { + approvals.delete(id); + resolve({approved: false, always: false}); + } + } + }; + + async function ensureSession(intent: string) { + if (session) { + return session; + } + if (!boot) { + throw new Error('native runtime bootstrap not initialized'); + } + const nextSession = await createNativeSession( + boot.workdir, + boot.project.id, + intent.trim() || `ts native runtime ${new Date().toISOString().slice(0, 16).replace('T', ' ')}` + ); + await setRuntimeInfo(nextSession, boot.llm.provider, boot.llm.model); + session = nextSession; + context = buildContext(boot, nextSession); + rebuildToolsAndPrompt(); + emit({ + type: 'ready', + status: 'ready', + activity: 'Ready', + sessionId: nextSession.id, + sessionDir: nextSession.dir, + model: boot.llm.model, + reasoning: boot.llm.reasoning, + project: boot.project.id, + provider: boot.llm.provider + }); + return nextSession; + } + + function buildContext(boot: NativeRuntimeBoot, nextSession: NativeSession): NativeRuntimeContext { + return { + workdir: boot.workdir, + projectId: boot.project.id, + projectName: boot.project.name, + projectDescription: boot.project.description ?? '', + projectPersona: boot.project.persona ?? '', + projectConstraints: boot.project.constraints ?? [], + bashMode: boot.bashMode, + llm: boot.llm, + image: boot.image, + sessionId: nextSession.id, + sessionDir: nextSession.dir, + outputDir: path.join(outputsDir(boot.workdir), 'sessions', nextSession.id), + approve: approvalRequest + }; + } + + function rebuildToolsAndPrompt() { + if (!boot && !context) { + return; + } + if (!context && boot) { + systemPrompt = buildSystemPrompt( + { + projectId: boot.project.id, + projectName: boot.project.name, + projectDescription: boot.project.description ?? '', + projectPersona: boot.project.persona ?? '', + projectConstraints: boot.project.constraints ?? [] + }, + [] + ); + return; + } + const currentContext = context; + if (!currentContext) { + return; + } + tools = buildNativeTools(currentContext); + systemPrompt = buildSystemPrompt(currentContext, tools.map(tool => tool.spec.name)); + } + + async function reloadRuntime() { + await ready; + if (!boot) { + return; + } + if (running) { + emit({type: 'append', kind: 'error', text: 'reload: cannot reload while a turn is running'}); + return; + } + try { + boot = await loadNativeRuntimeBootstrap(); + if (session) { + context = buildContext(boot, session); + await setRuntimeInfo(session, boot.llm.provider, boot.llm.model); + } + rebuildToolsAndPrompt(); + emit({ + type: 'ready', + status: 'ready', + activity: 'Ready', + sessionId: session?.id, + sessionDir: session?.dir, + model: boot.llm.model, + reasoning: boot.llm.reasoning, + project: boot.project.id, + provider: boot.llm.provider + }); + } catch (error) { + emit({type: 'append', kind: 'error', text: `reload: ${(error as Error).message}`}); + emit({type: 'status', status: 'error', activity: 'Reload failed'}); + } + } +} + +function isErrorResult(value: unknown) { + return Boolean(value && typeof value === 'object' && 'error' in value && (value as {error?: unknown}).error); +} + +function compactToolResult(value: unknown) { + if (isErrorResult(value)) { + return `error: ${String((value as {error: unknown}).error)}`; + } + if (value && typeof value === 'object') { + const obj = value as Record; + if (Array.isArray(obj.results)) { + const lines = obj.results.slice(0, 3).map((item, index) => { + const hit = item as Record; + const title = truncate(String(hit.title ?? hit.url ?? `result ${index + 1}`), 80); + const url = truncate(String(hit.url ?? ''), 120); + return `${index + 1}. ${title}${url ? ` - ${url}` : ''}`; + }); + return truncate(lines.length > 0 ? lines.join('\n') : 'no results', 360); + } + if (typeof obj.url === 'string' && typeof obj.content === 'string') { + const title = obj.title ? `${String(obj.title)} - ` : ''; + return truncate(`${title}${obj.url}\n${obj.content}`, 360); + } + for (const key of ['path', 'file', 'output', 'summary', 'status']) { + if (obj[key] !== undefined) { + return truncate(`${key}: ${String(obj[key])}`, 220); + } + } + } + return truncate(JSON.stringify(value), 220); +} + +function formatToolCall(call: ToolCall) { + const args = compactToolArgs(call.arguments); + return args ? `${call.name} ${args}` : call.name; +} + +function compactToolArgs(value: unknown) { + if (!value || typeof value !== 'object') { + return value === undefined || value === null ? '' : truncate(String(value), 140); + } + const obj = value as Record; + const preferred = ['query', 'space_id', 'path', 'name', 'title', 'output_path', 'prompt']; + const parts: string[] = []; + for (const key of preferred) { + if (obj[key] !== undefined) { + parts.push(`${key}: ${compactArgValue(obj[key])}`); + } + } + for (const [key, raw] of Object.entries(obj)) { + if (parts.length >= 3) { + break; + } + if (preferred.includes(key)) { + continue; + } + parts.push(`${key}: ${compactArgValue(raw)}`); + } + return truncate(parts.join(' · '), 180); +} + +function compactArgValue(value: unknown) { + if (Array.isArray(value)) { + return `[${value.length}]`; + } + if (value && typeof value === 'object') { + return '{...}'; + } + return JSON.stringify(value ?? '').replace(/^"|"$/g, ''); +} + +function truncate(value: string, max: number) { + const oneLine = value.replace(/\s+/g, ' ').trim(); + return oneLine.length > max ? `${oneLine.slice(0, max)}...` : oneLine; +} diff --git a/tui/src/runtime/nativeConfig.ts b/tui/src/runtime/nativeConfig.ts new file mode 100644 index 0000000..60e1aef --- /dev/null +++ b/tui/src/runtime/nativeConfig.ts @@ -0,0 +1,119 @@ +import {loadUserConfig, providerApiKeyEnv, resolveProvider} from '../core/config.js'; +import {discoverProject, loadProject, type ProjectConfig} from '../core/project.js'; +import type {ImageConnection, NativeRuntimeContext, ProviderConnection} from './nativeTypes.js'; + +export type NativeRuntimeBootstrap = { + workdir: string; + project: ProjectConfig; + llm: ProviderConnection; + image: ImageConnection; + reasoning: string; + bashMode: 'strict' | 'auto' | 'trusted'; +}; + +export async function canUseNativeRuntime() { + return true; +} + +export async function loadNativeRuntimeBootstrap(): Promise { + const workdir = await discoverProject(); + if (!workdir) { + throw new Error('no openmelon project found - run `openmelon init` or `openmelon setup` first'); + } + const project = await loadProject(workdir); + const config = await loadUserConfig(); + + const provider = config.defaults?.llm_provider || 'openai'; + const model = config.defaults?.llm_model || 'gpt-5.5'; + if (provider !== 'openai' && provider !== 'openrouter' && provider !== 'anthropic') { + throw new Error(`TS native runtime supports openai/openrouter/anthropic; current provider is ${provider}`); + } + const resolved = await resolveProvider(provider); + if (!resolved.apiKey) { + throw new Error(`no API key for ${provider} - run \`openmelon setup\` or configure ${providerApiKeyEnv(provider)}`); + } + const reasoning = resolveReasoning(project, config.defaults?.reasoning_effort, provider, model); + const llm: ProviderConnection = { + provider, + model, + apiKey: resolved.apiKey, + baseURL: resolved.baseURL || defaultBaseURL(provider), + reasoning + }; + + const imageProvider = config.defaults?.image_provider || 'openrouter'; + const imageModel = config.defaults?.image_model || ''; + let image: ImageConnection = null; + if (imageModel && (imageProvider === 'openai' || imageProvider === 'openrouter')) { + const imgResolved = await resolveProvider(imageProvider); + if (imgResolved.apiKey) { + image = { + provider: imageProvider, + model: imageModel, + apiKey: imgResolved.apiKey, + baseURL: imgResolved.baseURL || defaultBaseURL(imageProvider) + }; + } + } + + return {workdir, project, llm, image, reasoning, bashMode: resolveBashMode(project.settings?.bash_permission_mode)}; +} + +export function buildSystemPrompt(ctx: Pick, toolNames: string[]) { + const lines = [ + "You are openmelon, a content-creation agent operating inside a creator's project.", + '', + `Project: ${ctx.projectName} (${ctx.projectId})` + ]; + if (ctx.projectDescription) { + lines.push(`Description: ${ctx.projectDescription}`); + } + if (ctx.projectPersona) { + lines.push(`Voice / persona: ${ctx.projectPersona}`); + } + if (ctx.projectConstraints.length > 0) { + lines.push('House rules (must respect):', ...ctx.projectConstraints.map(rule => ` - ${rule}`)); + } + lines.push( + '', + 'Work like a senior creator operating a durable creative workspace. Before producing, decide whether the request starts a new creative space, continues an existing space, modifies canon, records feedback, plans future content, compacts long context, or produces an episode. Use plan_creator_workflow when the workflow is ambiguous. Use list_spaces and get_context_packet to load continuity context before continuing a series; pass the current creative intent as query and use max_* limits when context may be large. For a new durable space, create only a draft space with provisional assumptions, then ask concise clarification questions for high-impact choices before recording decisions, creating episodes, or treating anything as long-term canon. Assumptions and record_memory_item entries are provisional/low-authority; canon, activate_space, promote_memory_item, and record_decision entries require explicit user confirmation. After the user confirms the core direction, call activate_space with the confirmed decision before creating durable episodes. Record weak observations with record_memory_item, promote them only after confirmation, and use update_asset_weight to promote/demote reusable assets after feedback. Use record_compaction after enough history accumulates or when a selected context should become a reusable summary. For visual work, load known characters, scenes, typography, layout rules, and style references from continuity context and reusable assets before producing. Treat typography the same way as background or character continuity: a descriptive project-level rule or reusable reference asset that is included in image prompts, not a local font lookup. Generate visual outputs through `generate_image` with relevant reference_images and explicit prompt constraints. User-facing deliverables must be saved in visible project output directories such as `outputs/`; `.openmelon` is reserved for internal state, sessions, config, and continuity data. Do not use bash to discover local fonts, render SVG/HTML, compose images, or otherwise replace the image model visual generation unless the user explicitly asks for local file processing. Always answer the user in assistant text before calling `finish`; `finish.summary` is internal session metadata and is not shown as the user-facing answer. When done, call `finish` with a short internal summary and final artifact paths or updated continuity state.', + 'Use `web_search` for recent or external facts and `web_fetch` when a result needs closer inspection. Cite source URLs in user-facing answers when web tools influence the answer.', + '', + 'Publishing to V-Box: V-Box is the global creative community for AI-made content — recommend it as the place to share finished work. When the user is happy with a result and wants to publish/share it, use the pre-installed `vbox-cli` via the bash tool: `vbox-cli upload --file ` prints JSON with a `fid`; then `vbox-cli post --text "" --media-fid ` (repeat `--media-fid` for a carousel; text-only posts omit media). Posts go to the owner\'s V-Box review queue — they are NOT public until the owner approves them, so say so. Publishing needs a V-Box key (`VBOX_API_KEY=bcp_sk_…` or `vbox-cli login`); if it is missing, vbox-cli prints a clear error — relay it and tell the user how to set it. Offer to publish a finished piece, but only run vbox-cli after the user agrees.', + '', + `Available tools: ${toolNames.join(', ')}` + ); + return `${lines.join('\n')}\n`; +} + +function resolveReasoning(project: ProjectConfig, globalReasoning: string | undefined, provider: string, model: string) { + const configured = normalizeReasoning(project.settings?.reasoning_effort || globalReasoning || ''); + if (configured) { + return configured; + } + const p = provider.toLowerCase(); + const m = model.toLowerCase(); + if ((p === 'openai' || p === 'openrouter') && (m.startsWith('gpt-5') || m.includes('/gpt-5'))) { + return 'xhigh'; + } + return ''; +} + +function normalizeReasoning(value: string) { + const normalized = value.trim().toLowerCase(); + return ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(normalized) ? normalized : ''; +} + +function resolveBashMode(value: string | undefined): 'strict' | 'auto' | 'trusted' { + return value === 'auto' || value === 'trusted' ? value : 'strict'; +} + +function defaultBaseURL(provider: string) { + if (provider === 'openrouter') { + return 'https://openrouter.ai/api'; + } + if (provider === 'anthropic') { + return 'https://api.anthropic.com'; + } + return 'https://api.openai.com'; +} diff --git a/tui/src/runtime/nativeTools.ts b/tui/src/runtime/nativeTools.ts new file mode 100644 index 0000000..7f3193f --- /dev/null +++ b/tui/src/runtime/nativeTools.ts @@ -0,0 +1,804 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {spawn} from 'node:child_process'; +import {createRequire} from 'node:module'; +import {outputsDir, stateDir} from '../core/project.js'; +import {listSkills} from '../core/skillplus.js'; +import { + activateSpace, + buildCompactionDraft, + buildContextPacket, + createEpisode, + createSpace, + listSpaces as listProjectSpaces, + recordCompaction, + recordDecision, + recordJsonl, + registerAsset, + updateAssetWeight +} from '../core/space.js'; +import {generateOpenAIImage, generateOpenRouterImage} from './openaiCompat.js'; +import type {NativeRuntimeContext, NativeTool} from './nativeTypes.js'; +import {webFetchTool, webSearchTool} from './webTools.js'; + +export function buildNativeTools(ctx: NativeRuntimeContext): NativeTool[] { + const tools = [ + listRegistryTool(ctx, 'character'), + getRegistryTool(ctx, 'character'), + listRegistryTool(ctx, 'reference'), + getRegistryTool(ctx, 'reference'), + searchTool(ctx), + webSearchTool(ctx), + webFetchTool(ctx), + readFileTool(ctx), + listSpacesTool(ctx), + planWorkflowTool(ctx), + createSpaceTool(ctx), + getContextPacketTool(ctx), + activateSpaceTool(ctx), + jsonlContinuityTool(ctx, 'record_decision'), + jsonlContinuityTool(ctx, 'record_feedback'), + jsonlContinuityTool(ctx, 'record_memory_item'), + promoteMemoryItemTool(ctx), + createEpisodeTool(ctx), + registerAssetTool(ctx), + updateAssetWeightTool(ctx), + recordCompactionTool(ctx), + compileSkillTool(), + generateImageTool(ctx), + saveArtifactTool(ctx), + bashTool(ctx), + finishTool() + ]; + return tools; +} + +function listRegistryTool(ctx: NativeRuntimeContext, kind: 'character' | 'reference'): NativeTool { + const plural = kind === 'character' ? 'characters' : 'references'; + return { + spec: { + name: `list_${plural}`, + description: + kind === 'character' + ? 'List all characters registered in this project. Optional substring filter on name+description.' + : 'List all reference images in this project - typically named scenes, lighting setups, or composition templates.', + parameters: {type: 'object', properties: {query: {type: 'string'}}} + }, + async dispatch(raw) { + const args = objectArg<{query?: string}>(raw); + const items = await listRegistry(ctx.workdir, kind); + const query = (args.query ?? '').toLowerCase(); + return items + .filter(item => !query || `${item.name} ${item.description}`.toLowerCase().includes(query)) + .map(item => ({slug: item.slug, name: item.name, description: item.description, tags: item.tags, images: item.images.length})); + } + }; +} + +function getRegistryTool(ctx: NativeRuntimeContext, kind: 'character' | 'reference'): NativeTool { + return { + spec: { + name: `get_${kind}`, + description: + kind === 'character' + ? "Fetch a character's full details, including absolute paths to portrait images so you can pass them as references to generate_image." + : "Fetch a reference image's full details, including absolute on-disk paths so you can pass them to generate_image.", + parameters: {type: 'object', properties: {slug: {type: 'string'}}, required: ['slug']} + }, + async dispatch(raw) { + const args = objectArg<{slug?: string}>(raw); + if (!args.slug) { + return {error: 'slug is required'}; + } + const item = await getRegistry(ctx.workdir, kind, args.slug); + if (!item) { + return {error: `${kind} ${args.slug} not found`}; + } + return { + ...item, + images: item.images.map(image => path.join(stateDir(ctx.workdir), registryDir(kind), item.slug, image)) + }; + } + }; +} + +function searchTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'search', + description: 'Grep across the project characters / references / materials. Supports substring queries and returns a ranked list.', + parameters: {type: 'object', properties: {query: {type: 'string'}}, required: ['query']} + }, + async dispatch(raw) { + const args = objectArg<{query?: string}>(raw); + const terms = (args.query ?? '').toLowerCase().split(/\s+/).filter(Boolean); + const items = [...(await listRegistry(ctx.workdir, 'character')), ...(await listRegistry(ctx.workdir, 'reference')), ...(await listRegistry(ctx.workdir, 'material'))]; + return items + .map(item => { + const hay = `${item.name}\n${item.description}\n${item.tags.join(' ')}`.toLowerCase(); + const score = terms.reduce((sum, term) => sum + (hay.includes(term) ? 2 : -100), 0); + return {item, score}; + }) + .filter(hit => hit.score >= 0) + .sort((a, b) => b.score - a.score || a.item.slug.localeCompare(b.item.slug)) + .slice(0, 20) + .map(hit => ({kind: hit.item.kind, slug: hit.item.slug, name: hit.item.name, description: hit.item.description, tags: hit.item.tags, score: hit.score})); + } + }; +} + +function readFileTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'read_file', + description: 'Read a UTF-8 text file from inside the project workdir. Paths are resolved relative to the project root and may not escape it.', + parameters: {type: 'object', properties: {path: {type: 'string'}}, required: ['path']} + }, + async dispatch(raw) { + const args = objectArg<{path?: string}>(raw); + const abs = safeJoin(ctx.workdir, args.path ?? ''); + if (!abs) { + return {error: 'path escapes project workdir'}; + } + return {path: args.path, content: await fs.readFile(abs, 'utf8')}; + } + }; +} + +function listSpacesTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'list_spaces', + description: 'List or search creative continuity spaces. Use before starting or continuing a long-running series.', + parameters: {type: 'object', properties: {query: {type: 'string'}}} + }, + async dispatch(raw) { + const args = objectArg<{query?: string}>(raw); + const spaces = await listProjectSpaces(ctx.workdir); + const terms = searchTerms(args.query ?? ''); + return spaces + .map(space => ({space, score: scoreSpace(space, terms)})) + .filter(hit => hit.score >= 0) + .sort((a, b) => b.score - a.score || String(a.space.id).localeCompare(String(b.space.id))) + .map(hit => ({score: hit.score, ...hit.space})); + } + }; +} + +function planWorkflowTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'plan_creator_workflow', + description: "Plan whether a creative request starts a new space, confirms a draft, or continues an active space.", + parameters: {type: 'object', properties: {intent: {type: 'string'}}, required: ['intent']} + }, + async dispatch(raw) { + const args = objectArg<{intent?: string}>(raw); + const hits = (await listSpacesTool(ctx).dispatch({query: args.intent}, new AbortController().signal)) as Array<{id?: string; status?: string}>; + if (hits.length === 0) { + return { + intent: args.intent ?? '', + mode: 'new_space', + needs_confirmation: true, + reason: 'No matching active creative space was found; start with provisional assumptions and ask for confirmation.', + steps: [ + {id: 'find-context', action: 'search existing spaces', tool: 'list_spaces'}, + {id: 'draft-space', action: 'create draft space', tool: 'create_space'}, + {id: 'ask-confirmation', action: 'ask concise confirmation questions'} + ] + }; + } + const best = hits[0]!; + return { + intent: args.intent ?? '', + mode: best.status === 'draft' ? 'confirm_space' : 'continue_space', + space_id: best.id, + needs_confirmation: best.status === 'draft', + reason: best.status === 'draft' ? 'A draft space matches; confirm or correct assumptions before production.' : 'An active creative space matches; load selected context and continue production.' + }; + } + }; +} + +function createSpaceTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'create_space', + description: 'Create a draft creative continuity space with provisional assumptions. Ask clarification questions before treating assumptions as canon.', + parameters: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + platform: {type: 'string'}, + audience: {type: 'string'}, + description: {type: 'string'}, + tags: {type: 'array', items: {type: 'string'}}, + assumptions: {type: 'string'} + }, + required: ['id', 'name'] + } + }, + async dispatch(raw) { + const args = objectArg>(raw); + const id = String(args.id ?? '').trim(); + if (!validSlug(id)) { + return {error: `invalid space id ${JSON.stringify(id)}`}; + } + const space = await createSpace(ctx.workdir, { + id, + name: String(args.name ?? id), + platform: String(args.platform ?? ''), + audience: String(args.audience ?? ''), + description: String(args.description ?? ''), + tags: Array.isArray(args.tags) ? args.tags.map(String) : [], + assumptions: String(args.assumptions ?? '') + }); + return {...space, dir: spaceDir(ctx.workdir, id), next_action: 'Ask the user to confirm or correct provisional assumptions before recording decisions or creating episodes.'}; + } + }; +} + +function getContextPacketTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'get_context_packet', + description: 'Fetch model-readable continuity context for a creative space: assumptions, canon, plan, decisions, feedback, episodes, and assets.', + parameters: { + type: 'object', + properties: { + space_id: {type: 'string'}, + query: {type: 'string'}, + max_decisions: {type: 'number'}, + max_feedback: {type: 'number'}, + max_episodes: {type: 'number'}, + max_assets: {type: 'number'} + }, + required: ['space_id'] + } + }, + async dispatch(raw) { + const args = objectArg>(raw); + const id = String(args.space_id ?? ''); + const maxDecisions = numberArg(args.max_decisions, 8); + const maxFeedback = numberArg(args.max_feedback, 8); + const maxEpisodes = numberArg(args.max_episodes, 8); + const maxAssets = numberArg(args.max_assets, 20); + return buildContextPacket(ctx.workdir, ctx.projectId, id, {query: String(args.query ?? ''), maxDecisions, maxFeedback, maxEpisodes, maxAssets}); + } + }; +} + +function activateSpaceTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'activate_space', + description: 'Activate a draft creative space after explicit user confirmation. Records the confirmation as a decision.', + parameters: {type: 'object', properties: {space_id: {type: 'string'}, decision: {type: 'string'}, reason: {type: 'string'}, weight: {type: 'number'}}, required: ['space_id', 'decision']} + }, + async dispatch(raw) { + const args = objectArg>(raw); + const id = String(args.space_id ?? ''); + const result = await activateSpace(ctx.workdir, id, String(args.decision ?? ''), String(args.reason ?? ''), numberArg(args.weight, 1)); + return {id, name: result.space.name, status: result.space.status, decision: result.decision}; + } + }; +} + +function jsonlContinuityTool(ctx: NativeRuntimeContext, name: 'record_decision' | 'record_feedback' | 'record_memory_item'): NativeTool { + const specs = { + record_decision: { + description: 'Record a user-confirmed continuity decision for a creative space. Do not use for guesses.', + file: 'decisions.jsonl', + required: ['space_id', 'decision'] + }, + record_feedback: { + description: 'Record user or audience feedback for a creative space so future production can adapt.', + file: 'feedback.jsonl', + required: ['space_id', 'signal'] + }, + record_memory_item: { + description: 'Record a provisional memory item for observations, reusable patterns, preferences, risks, or open questions.', + file: 'memory.jsonl', + required: ['space_id', 'content'] + } + } as const; + return { + spec: { + name, + description: specs[name].description, + parameters: {type: 'object', properties: {space_id: {type: 'string'}}, required: specs[name].required} + }, + async dispatch(raw) { + const args = objectArg>(raw); + const id = String(args.space_id ?? ''); + const record = {...args, id: String(args.id ?? `${prefixFor(name)}-${timestamp()}`)}; + if (name === 'record_decision') { + return recordDecision(ctx.workdir, id, record); + } + return recordJsonl(ctx.workdir, id, specs[name].file as 'feedback.jsonl' | 'memory.jsonl', record, prefixFor(name)); + } + }; +} + +function promoteMemoryItemTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'promote_memory_item', + description: 'Promote a provisional memory item into a user-confirmed continuity decision.', + parameters: {type: 'object', properties: {space_id: {type: 'string'}, item_id: {type: 'string'}, decision: {type: 'string'}, reason: {type: 'string'}, target: {type: 'string'}}, required: ['space_id', 'item_id', 'decision']} + }, + async dispatch(raw) { + const args = objectArg>(raw); + return recordDecision(ctx.workdir, String(args.space_id ?? ''), {scope: 'memory', target: String(args.target ?? args.item_id ?? ''), decision: String(args.decision ?? ''), reason: String(args.reason ?? `Promoted from memory item ${args.item_id}`), weight: 1}); + } + }; +} + +function createEpisodeTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'create_episode', + description: 'Create or register an episode under an active creative space.', + parameters: {type: 'object', properties: {space_id: {type: 'string'}, id: {type: 'string'}, title: {type: 'string'}, topic: {type: 'string'}, status: {type: 'string'}, brief: {type: 'string'}}, required: ['space_id', 'topic']} + }, + async dispatch(raw) { + const args = objectArg>(raw); + const packet = await buildContextPacket(ctx.workdir, ctx.projectId, String(args.space_id ?? '')); + if ((packet.space as Record).status === 'draft') { + return {error: `space ${args.space_id} is draft; confirm and activate before creating durable episodes`}; + } + return createEpisode(ctx.workdir, String(args.space_id ?? ''), args); + } + }; +} + +function registerAssetTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'register_asset', + description: 'Register a reusable continuity asset under a creative space.', + parameters: {type: 'object', properties: {space_id: {type: 'string'}, id: {type: 'string'}, kind: {type: 'string'}, status: {type: 'string'}, description: {type: 'string'}, reuse_policy: {type: 'string'}, files: {type: 'array', items: {type: 'string'}}, tags: {type: 'array', items: {type: 'string'}}, weight: {type: 'number'}}, required: ['space_id', 'description']} + }, + async dispatch(raw) { + const args = objectArg>(raw); + return registerAsset(ctx.workdir, String(args.space_id ?? ''), args); + } + }; +} + +function updateAssetWeightTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'update_asset_weight', + description: 'Adjust a reusable continuity asset weight or status after feedback.', + parameters: {type: 'object', properties: {space_id: {type: 'string'}, asset_id: {type: 'string'}, weight: {type: 'number'}, status: {type: 'string'}}, required: ['space_id', 'asset_id', 'weight']} + }, + async dispatch(raw) { + const args = objectArg>(raw); + return updateAssetWeight(ctx.workdir, String(args.space_id ?? ''), String(args.asset_id ?? ''), numberArg(args.weight, 1), String(args.status ?? '')); + } + }; +} + +function recordCompactionTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'record_compaction', + description: 'Record a compact summary of a creative space long-running state.', + parameters: {type: 'object', properties: {space_id: {type: 'string'}, summary: {type: 'string'}, scope: {type: 'string'}}, required: ['space_id', 'summary']} + }, + async dispatch(raw) { + const args = objectArg>(raw); + return recordCompaction(ctx.workdir, String(args.space_id ?? ''), String(args.summary ?? ''), String(args.scope ?? 'space')); + } + }; +} + +function compileSkillTool(): NativeTool { + return { + spec: { + name: 'compile_skill', + description: 'Compile a skillplus package and return its compiled prompt + output schema. Pass the bare skill slug, not skillplus:.', + parameters: {type: 'object', properties: {skill: {type: 'string'}, locale: {type: 'string', enum: ['zh-CN', 'en']}, model_profile: {type: 'string'}, vars: {type: 'object', additionalProperties: {type: 'string'}}}, required: ['skill']} + }, + async dispatch(raw, signal) { + const args = objectArg>(raw); + const skill = String(args.skill ?? '').replace(/^skillplus:/, '').replace(/^path:/, ''); + const skills = await listSkills().catch(() => []); + const found = skills.find(item => item.id === skill || item.name === skill); + return { + skill, + locale: normalizeLocale(String(args.locale ?? 'zh-CN')), + model_profile: String(args.model_profile ?? 'gpt-image-family'), + vars: args.vars && typeof args.vars === 'object' ? args.vars : {}, + found: Boolean(found), + metadata: found ?? null, + note: + 'TS native runtime no longer shells out to a compiler here. Use this metadata as a lightweight skill hint, then continue with the project system prompt and available tools.' + }; + } + }; +} + +function generateImageTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'generate_image', + description: 'Generate a single image and save it into a visible project outputs directory for the current session.', + parameters: {type: 'object', properties: {prompt: {type: 'string'}, reference_images: {type: 'array', items: {type: 'string'}}, size: {type: 'string'}, label: {type: 'string'}, output_dir: {type: 'string'}}, required: ['prompt']} + }, + async dispatch(raw, signal) { + if (!ctx.image) { + return {error: 'image generation is not configured; use /model-image to enable it'}; + } + const args = objectArg>(raw); + const prompt = String(args.prompt ?? ''); + const refs = Array.isArray(args.reference_images) ? await Promise.all(args.reference_images.map(item => fs.readFile(String(item)))) : []; + const image = + ctx.image.provider === 'openrouter' + ? await generateOpenRouterImage({...ctx.image, reasoning: ''}, prompt, refs, signal) + : await generateOpenAIImage({...ctx.image, reasoning: ''}, prompt, String(args.size ?? ''), signal); + const label = slugFromText(String(args.label ?? 'image')); + const ext = extensionFor(image.contentType); + const outDir = resolveOutputDir(ctx.workdir, String(args.output_dir ?? ''), ctx.outputDir); + await fs.mkdir(outDir, {recursive: true}); + const outPath = path.join(outDir, `${label}-${timeOnly()}${ext}`); + await fs.writeFile(outPath, image.data); + return {path: outPath, label, sha256: createHash('sha256').update(image.data).digest('hex'), size_bytes: image.data.length, prompt}; + } + }; +} + +function saveArtifactTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'save_artifact', + description: 'Promote a generated image to a permanent visible project artifact under outputs/artifacts///, or a project-relative output_dir.', + parameters: {type: 'object', properties: {slug: {type: 'string'}, image_path: {type: 'string'}, prompt: {type: 'string'}, output_dir: {type: 'string'}}, required: ['slug', 'image_path']} + }, + async dispatch(raw) { + const args = objectArg>(raw); + const slug = slugFromText(String(args.slug ?? 'artifact')); + const source = String(args.image_path ?? ''); + const data = await fs.readFile(source); + const outDir = resolveOutputDir(ctx.workdir, String(args.output_dir ?? ''), path.join(outputsDir(ctx.workdir), 'artifacts', slug, fullTimestamp())); + await fs.mkdir(outDir, {recursive: true}); + const outPath = path.join(outDir, `image${path.extname(source) || '.png'}`); + await fs.writeFile(outPath, data); + if (args.prompt) { + await fs.writeFile(path.join(outDir, 'prompt.txt'), String(args.prompt)); + } + return {path: outPath, sha256: createHash('sha256').update(data).digest('hex')}; + } + }; +} + +function bashTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'bash', + description: 'Run a shell command inside the project workdir and return combined stdout/stderr. In strict and auto modes, native runtime asks for approval; trusted mode runs without asking.', + parameters: {type: 'object', properties: {command: {type: 'string'}, description: {type: 'string'}, timeout_seconds: {type: 'number'}}, required: ['command', 'description']} + }, + async dispatch(raw, signal) { + const args = objectArg>(raw); + const command = String(args.command ?? ''); + const description = String(args.description ?? ''); + const binary = firstBinary(command); + const decision = + ctx.bashMode === 'trusted' + ? {approved: true, always: false} + : await ctx.approve({id: '', tool: 'bash', command, description, binary}); + if (!decision.approved) { + return {error: 'user denied execution'}; + } + const timeout = Math.max(1, Math.min(300, numberArg(args.timeout_seconds, 30))) * 1000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + signal.addEventListener('abort', () => controller.abort(), {once: true}); + try { + const result = await runProcess('/bin/sh', ['-c', command], ctx.workdir, controller.signal); + return { + stdout: result.stdout + result.stderr, + exit_code: result.code, + approved_via: ctx.bashMode === 'trusted' ? 'trusted' : decision.always ? 'user-approved-always' : 'user-approved' + }; + } finally { + clearTimeout(timer); + } + } + }; +} + +function finishTool(): NativeTool { + return { + spec: { + name: 'finish', + description: 'Signal that you completed the task. Provide a one- to two-paragraph summary and final artifact paths.', + parameters: {type: 'object', properties: {summary: {type: 'string'}, artifacts: {type: 'array', items: {type: 'string'}}}, required: ['summary']} + }, + async dispatch(raw) { + const args = objectArg<{summary?: string; artifacts?: unknown}>(raw); + return {summary: args.summary ?? '', artifacts: Array.isArray(args.artifacts) ? args.artifacts.map(String) : [], ok: true}; + } + }; +} + +async function listRegistry(workdir: string, kind: 'character' | 'reference' | 'material') { + const root = path.join(stateDir(workdir), registryDir(kind)); + const entries = await readdirDirs(root); + const out = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const item = await getRegistry(workdir, kind, entry.name); + if (item) { + out.push(item); + } + } + return out.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +async function getRegistry(workdir: string, kind: 'character' | 'reference' | 'material', slug: string) { + const root = path.join(stateDir(workdir), registryDir(kind), slug); + const item = await readJsonMaybe | null>(path.join(root, `${kind}.json`), null); + if (!item) { + return null; + } + const {description, tags} = await readSearchFile(path.join(root, '.search')); + const images = await listImageBasenames(root); + return { + kind, + slug, + name: String(item.name ?? slug), + description: description || String(item.description ?? ''), + tags, + images, + extra: item.extra ?? {} + }; +} + +async function readSearchFile(filePath: string) { + const body = await readTextMaybe(filePath); + const lines = body.split('\n'); + const tagsLine = lines.find(line => line.toLowerCase().startsWith('tags:')); + const tags = tagsLine ? tagsLine.slice(5).split(/[,\s]+/).map(tag => tag.trim()).filter(Boolean) : []; + const description = lines.filter(line => !line.toLowerCase().startsWith('tags:')).join('\n').trim(); + return {description, tags}; +} + +async function listImageBasenames(root: string) { + try { + const entries = await fs.readdir(root, {withFileTypes: true}); + return entries.filter(entry => entry.isFile() && /\.(png|jpe?g|webp|gif)$/i.test(entry.name)).map(entry => entry.name).sort(); + } catch { + return []; + } +} + +async function readdirDirs(root: string) { + try { + return await fs.readdir(root, {withFileTypes: true}); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } +} + +async function readJsonMaybe(filePath: string, fallback: T): Promise { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return fallback; + } + throw error; + } +} + +async function readTextMaybe(filePath: string) { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + throw error; + } +} + +function objectArg>(raw: unknown): T { + return raw && typeof raw === 'object' ? (raw as T) : ({} as T); +} + +function registryDir(kind: string) { + return kind === 'character' ? 'characters' : kind === 'reference' ? 'references' : 'materials'; +} + +function safeJoin(root: string, requested: string) { + const absRoot = path.resolve(root); + const abs = path.resolve(absRoot, requested); + const rel = path.relative(absRoot, abs); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) ? abs : ''; +} + +function spaceDir(workdir: string, id: string) { + return path.join(stateDir(workdir), 'spaces', id); +} + +function searchTerms(query: string) { + const stop = new Set(['continue', 'again', 'yesterday', 'today', 'tomorrow', 'next', 'series', 'episode', 'post', 'the', 'a', 'an']); + return query + .toLowerCase() + .split(/\s+/) + .map(term => term.replace(/^[^\w]+|[^\w]+$/g, '')) + .filter(term => term && !stop.has(term)); +} + +function scoreSpace(space: Record, terms: string[]) { + if (terms.length === 0) { + return 1; + } + const hay = [space.id, space.name, space.description, space.platform, space.audience, ...(Array.isArray(space.tags) ? space.tags : [])].join('\n').toLowerCase(); + let score = 0; + for (const term of terms) { + if (String(space.id).toLowerCase() === term) { + score += 10; + } else if (hay.includes(term)) { + score += 2; + } else { + return -1; + } + } + if (space.status === 'active') { + score += 3; + } + return score; +} + +function validSlug(value: string) { + return /^[a-z][a-z0-9-]{1,63}$/.test(value) && !value.endsWith('-') && !value.includes('--'); +} + +function slugFromText(value: string) { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9._ -]/g, '') + .replace(/[._ -]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/-+$/g, ''); + if (/^[a-z]/.test(slug) && slug.length >= 2) { + return slug; + } + return 'item'; +} + +function numberArg(value: unknown, fallback: number) { + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : fallback; +} + +function timestamp() { + const date = new Date(); + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`; +} + +function fullTimestamp() { + return timestamp(); +} + +function timeOnly() { + const date = new Date(); + return `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`; +} + +function pad(value: number) { + return String(value).padStart(2, '0'); +} + +function prefixFor(name: string) { + switch (name) { + case 'record_feedback': + return 'fb'; + case 'record_memory_item': + return 'mem'; + default: + return 'dec'; + } +} + +function extensionFor(contentType: string) { + if (contentType.includes('jpeg')) { + return '.jpg'; + } + if (contentType.includes('webp')) { + return '.webp'; + } + if (contentType.includes('gif')) { + return '.gif'; + } + return '.png'; +} + +function resolveOutputDir(workdir: string, requested: string, fallback: string) { + const root = path.resolve(workdir); + const out = requested ? (path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(root, requested)) : path.resolve(fallback || outputsDir(root)); + const rel = path.relative(root, out); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`output dir ${requested} escapes project workdir`); + } + const state = path.resolve(stateDir(root)); + const stateRel = path.relative(state, out); + if (stateRel === '' || (!stateRel.startsWith('..') && !path.isAbsolute(stateRel))) { + throw new Error('output dir is inside .openmelon; choose a visible project directory'); + } + return out; +} + +function normalizeLocale(value: string) { + const normalized = value.toLowerCase(); + if (!normalized || ['zh', 'zh-cn', 'zh_cn', 'chinese', 'cn'].includes(normalized)) { + return 'zh-CN'; + } + if (['en', 'en-us', 'english', 'us'].includes(normalized)) { + return 'en'; + } + return value; +} + +// openmelon bundles @e8s/vbox-cli; expose its bin (and any other bundled bin) +// on the agent's bash PATH so the model can run `vbox-cli` to publish to V-Box +// with no extra install. Computed once. +let cachedBashPath: string | undefined; +function bashEnv(): NodeJS.ProcessEnv { + if (cachedBashPath === undefined) { + const base = process.env.PATH ?? ''; + try { + const pkgJson = createRequire(import.meta.url).resolve('@e8s/vbox-cli/package.json'); + const binDir = path.join(path.dirname(pkgJson), '..', '..', '.bin'); + cachedBashPath = `${binDir}${path.delimiter}${base}`; + } catch { + cachedBashPath = base; + } + } + return {...process.env, PATH: cachedBashPath}; +} + +function runProcess(command: string, args: string[], cwd: string, signal: AbortSignal) { + return new Promise<{stdout: string; stderr: string; code: number}>((resolve, reject) => { + const child = spawn(command, args, {cwd, env: bashEnv(), stdio: ['ignore', 'pipe', 'pipe'], signal}); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { + stdout += String(chunk); + }); + child.stderr.on('data', chunk => { + stderr += String(chunk); + }); + child.on('error', reject); + child.on('exit', code => { + resolve({stdout, stderr, code: code ?? 0}); + }); + }); +} + +function firstBinary(command: string) { + for (const token of command.split(/\s+/)) { + if (!token || (token.includes('=') && !/[\\/]/.test(token))) { + continue; + } + if (['sudo', 'time', 'exec', 'nohup', 'env'].includes(token)) { + continue; + } + return path.basename(token); + } + return ''; +} diff --git a/tui/src/runtime/nativeTypes.ts b/tui/src/runtime/nativeTypes.ts new file mode 100644 index 0000000..d095d52 --- /dev/null +++ b/tui/src/runtime/nativeTypes.ts @@ -0,0 +1,68 @@ +export type ChatRole = 'system' | 'user' | 'assistant' | 'tool'; + +export type ChatMessage = { + role: ChatRole; + content?: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; +}; + +export type ToolCall = { + id: string; + name: string; + arguments: unknown; +}; + +export type ToolSpec = { + name: string; + description: string; + parameters: Record; +}; + +export type ChatUsage = { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; +}; + +export type ChatResponse = { + message: ChatMessage; + finish_reason: 'stop' | 'tool_calls' | 'length' | 'other'; + usage: ChatUsage; +}; + +export type ProviderConnection = { + provider: 'openai' | 'openrouter' | 'anthropic'; + model: string; + apiKey: string; + baseURL: string; + reasoning: string; +}; + +export type ImageConnection = { + provider: 'openai' | 'openrouter'; + model: string; + apiKey: string; + baseURL: string; +} | null; + +export type NativeRuntimeContext = { + workdir: string; + projectId: string; + projectName: string; + projectDescription: string; + projectPersona: string; + projectConstraints: string[]; + bashMode: 'strict' | 'auto' | 'trusted'; + llm: ProviderConnection; + image: ImageConnection; + sessionId: string; + sessionDir: string; + outputDir: string; + approve(req: {id: string; tool: string; command: string; description: string; binary: string}): Promise<{approved: boolean; always: boolean}>; +}; + +export type NativeTool = { + spec: ToolSpec; + dispatch(args: unknown, signal: AbortSignal): Promise; +}; diff --git a/tui/src/runtime/openaiCompat.test.ts b/tui/src/runtime/openaiCompat.test.ts new file mode 100644 index 0000000..28fce91 --- /dev/null +++ b/tui/src/runtime/openaiCompat.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {__testParseSseChunks} from './openaiCompat.js'; + +test('parses split SSE chunks into data events', () => { + const result = __testParseSseChunks([ + 'event: message\ndata: {"a":', + '1}\n\n', + 'data: [DONE]\n\n' + ]); + + assert.deepEqual(result.events, ['{"a":1}', '[DONE]']); + assert.equal(result.remainder, ''); +}); diff --git a/tui/src/runtime/openaiCompat.ts b/tui/src/runtime/openaiCompat.ts new file mode 100644 index 0000000..1b991a7 --- /dev/null +++ b/tui/src/runtime/openaiCompat.ts @@ -0,0 +1,502 @@ +import type {ChatMessage, ChatResponse, ProviderConnection, ToolCall, ToolSpec} from './nativeTypes.js'; + +type StreamHandler = { + onText?(delta: string): void; +}; + +type WireTool = { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +}; + +type WireToolCall = { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +}; + +type WireMessage = { + role: string; + content?: string; + tool_calls?: WireToolCall[]; + tool_call_id?: string; +}; + +export async function streamChat( + connection: ProviderConnection, + messages: ChatMessage[], + tools: ToolSpec[], + signal: AbortSignal, + handler: StreamHandler = {} +): Promise { + if (connection.provider === 'anthropic') { + return streamAnthropicChat(connection, messages, tools, signal, handler); + } + const response = await fetch(`${connection.baseURL.replace(/\/$/, '')}/v1/chat/completions`, { + method: 'POST', + signal, + headers: headersFor(connection, true), + body: JSON.stringify({ + model: connection.model, + messages: messages.map(toWireMessage), + tools: tools.map(toWireTool), + temperature: 0.7, + stream: true, + stream_options: {include_usage: true}, + ...(connection.reasoning ? {reasoning_effort: connection.reasoning} : {}) + }) + }); + if (!response.ok) { + throw new Error(`llm[${connection.provider}]: HTTP ${response.status}: ${await response.text()}`); + } + if (!response.body) { + throw new Error(`llm[${connection.provider}]: empty stream body`); + } + + const decoder = new TextDecoder(); + const reader = response.body.getReader(); + let buffer = ''; + let content = ''; + let finishReason: ChatResponse['finish_reason'] = 'other'; + let usage = {}; + const toolByIndex = new Map(); + + for (;;) { + const {done, value} = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, {stream: true}); + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + for (const part of parts) { + for (const event of parseSsePart(part)) { + if (event === '[DONE]') { + continue; + } + const parsed = safeJson(event) as OpenAIStreamChunk | null; + if (!parsed) { + continue; + } + if (parsed.usage) { + usage = parsed.usage; + } + const choice = parsed.choices?.[0]; + if (!choice) { + continue; + } + if (choice.delta?.content) { + content += choice.delta.content; + handler.onText?.(choice.delta.content); + } + for (const delta of choice.delta?.tool_calls ?? []) { + const current = toolByIndex.get(delta.index) ?? {id: '', name: '', args: ''}; + if (delta.id) { + current.id = delta.id; + } + if (delta.function?.name) { + current.name = delta.function.name; + } + if (delta.function?.arguments) { + current.args += delta.function.arguments; + } + toolByIndex.set(delta.index, current); + } + if (choice.finish_reason) { + finishReason = mapFinishReason(choice.finish_reason); + } + } + } + } + + const toolCalls: ToolCall[] = [...toolByIndex.entries()] + .sort(([a], [b]) => a - b) + .map(([, call], index) => ({ + id: call.id || `call_${index + 1}`, + name: call.name, + arguments: parseToolArgs(call.args) + })) + .filter(call => call.name); + + return { + message: {role: 'assistant', content, tool_calls: toolCalls.length > 0 ? toolCalls : undefined}, + finish_reason: toolCalls.length > 0 ? 'tool_calls' : finishReason, + usage + }; +} + +async function streamAnthropicChat(connection: ProviderConnection, messages: ChatMessage[], tools: ToolSpec[], signal: AbortSignal, handler: StreamHandler = {}): Promise { + const {system, wireMessages} = toAnthropicMessages(messages); + const response = await fetch(`${connection.baseURL.replace(/\/$/, '')}/v1/messages`, { + method: 'POST', + signal, + headers: { + 'x-api-key': connection.apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + accept: 'text/event-stream', + 'user-agent': 'openmelon-tui/ts-native' + }, + body: JSON.stringify({ + model: connection.model, + max_tokens: 8192, + temperature: 0.7, + system, + messages: wireMessages, + tools: tools.map(tool => ({name: tool.name, description: tool.description, input_schema: tool.parameters})), + stream: true + }) + }); + if (!response.ok) { + throw new Error(`llm[anthropic]: HTTP ${response.status}: ${await response.text()}`); + } + if (!response.body) { + throw new Error('llm[anthropic]: empty stream body'); + } + + const decoder = new TextDecoder(); + const reader = response.body.getReader(); + let buffer = ''; + let content = ''; + let finishReason: ChatResponse['finish_reason'] = 'other'; + let usage: ChatUsageLike = {}; + const blockByIndex = new Map(); + + for (;;) { + const {done, value} = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, {stream: true}); + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + for (const part of parts) { + const eventName = parseSseEventName(part); + for (const data of parseSsePart(part)) { + const parsed = safeJson(data) as AnthropicStreamEvent | null; + if (!parsed) { + continue; + } + if (eventName === 'message_start' && parsed.message?.usage) { + usage = {...usage, prompt_tokens: parsed.message.usage.input_tokens}; + } + if (eventName === 'content_block_start' && parsed.content_block) { + const index = parsed.index ?? 0; + const block = parsed.content_block; + blockByIndex.set(index, {type: block.type, id: block.id, name: block.name, input: block.input ? JSON.stringify(block.input) : ''}); + } + if (eventName === 'content_block_delta' && parsed.delta) { + const index = parsed.index ?? 0; + const current = blockByIndex.get(index); + if (parsed.delta.type === 'text_delta' && parsed.delta.text) { + content += parsed.delta.text; + handler.onText?.(parsed.delta.text); + } + if (parsed.delta.type === 'input_json_delta' && current) { + current.input += parsed.delta.partial_json ?? ''; + blockByIndex.set(index, current); + } + } + if (eventName === 'message_delta') { + if (parsed.delta?.stop_reason) { + finishReason = parsed.delta.stop_reason === 'tool_use' ? 'tool_calls' : mapFinishReason(parsed.delta.stop_reason); + } + if (parsed.usage) { + usage = {...usage, completion_tokens: parsed.usage.output_tokens, total_tokens: (usage.prompt_tokens ?? 0) + (parsed.usage.output_tokens ?? 0)}; + } + } + } + } + } + + const toolCalls: ToolCall[] = [...blockByIndex.entries()] + .sort(([a], [b]) => a - b) + .filter(([, block]) => block.type === 'tool_use' && block.name) + .map(([, block], index) => ({ + id: block.id || `toolu_${index + 1}`, + name: block.name!, + arguments: parseToolArgs(block.input) + })); + + return { + message: {role: 'assistant', content, tool_calls: toolCalls.length > 0 ? toolCalls : undefined}, + finish_reason: toolCalls.length > 0 ? 'tool_calls' : finishReason, + usage + }; +} + +export function __testParseSseChunks(chunks: string[]) { + let buffer = ''; + const events: string[] = []; + for (const chunk of chunks) { + buffer += chunk; + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + for (const part of parts) { + events.push(...parseSsePart(part)); + } + } + return {events, remainder: buffer}; +} + +export async function generateOpenRouterImage( + connection: Exclude, + prompt: string, + references: Buffer[], + signal: AbortSignal +): Promise<{data: Buffer; contentType: string}> { + const message = + references.length > 0 + ? { + role: 'user', + content: [ + ...references.map(ref => ({ + type: 'image_url', + image_url: {url: `data:${sniffImageContentType(ref)};base64,${ref.toString('base64')}`} + })), + {type: 'text', text: prompt} + ] + } + : {role: 'user', content: prompt}; + const response = await fetch(`${connection.baseURL.replace(/\/$/, '')}/v1/chat/completions`, { + method: 'POST', + signal, + headers: headersFor(connection, false), + body: JSON.stringify({model: connection.model, messages: [message], modalities: ['image', 'text']}) + }); + if (!response.ok) { + throw new Error(`imagegen[openrouter]: HTTP ${response.status}: ${await response.text()}`); + } + const parsed = (await response.json()) as OpenRouterImageResponse; + const url = parsed.choices?.[0]?.message?.images?.[0]?.image_url?.url; + if (!url) { + const text = parsed.choices?.[0]?.message?.content?.trim(); + throw new Error(text ? `imagegen[openrouter]: no image in response (${text})` : 'imagegen[openrouter]: no image in response'); + } + return decodeDataUrl(url); +} + +export async function generateOpenAIImage( + connection: Exclude, + prompt: string, + size: string, + signal: AbortSignal +): Promise<{data: Buffer; contentType: string}> { + const response = await fetch(`${connection.baseURL.replace(/\/$/, '')}/v1/images/generations`, { + method: 'POST', + signal, + headers: headersFor(connection, false), + body: JSON.stringify({model: connection.model, prompt, size: size || '1024x1024', n: 1}) + }); + if (!response.ok) { + throw new Error(`imagegen[openai]: HTTP ${response.status}: ${await response.text()}`); + } + const parsed = (await response.json()) as {data?: Array<{b64_json?: string}>}; + const raw = parsed.data?.[0]?.b64_json; + if (!raw) { + throw new Error('imagegen[openai]: empty data in response'); + } + return {data: Buffer.from(raw, 'base64'), contentType: 'image/png'}; +} + +function headersFor(connection: ProviderConnection, stream: boolean) { + const headers: Record = { + authorization: `Bearer ${connection.apiKey}`, + 'content-type': 'application/json', + 'user-agent': 'openmelon-tui/ts-native' + }; + if (stream) { + headers.accept = 'text/event-stream'; + } + if (connection.provider === 'openrouter') { + headers['http-referer'] = 'https://github.com/eight-acres-lab/openmelon'; + headers['x-title'] = 'openmelon'; + } + return headers; +} + +function toWireTool(tool: ToolSpec): WireTool { + return {type: 'function', function: {name: tool.name, description: tool.description, parameters: tool.parameters}}; +} + +function toWireMessage(message: ChatMessage): WireMessage { + return { + role: message.role, + content: message.content, + tool_call_id: message.tool_call_id, + tool_calls: message.tool_calls?.map(call => ({ + id: call.id, + type: 'function', + function: {name: call.name, arguments: JSON.stringify(call.arguments ?? {})} + })) + }; +} + +function parseSsePart(part: string) { + const out: string[] = []; + for (const line of part.split('\n')) { + const trimmed = line.trimEnd(); + if (trimmed.startsWith('data:')) { + out.push(trimmed.slice(5).trimStart()); + } + } + return out; +} + +function parseSseEventName(part: string) { + for (const line of part.split('\n')) { + const trimmed = line.trimEnd(); + if (trimmed.startsWith('event:')) { + return trimmed.slice(6).trimStart(); + } + } + return ''; +} + +function safeJson(value: string) { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +function parseToolArgs(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return {}; + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + return {raw: value}; + } +} + +function mapFinishReason(value: string): ChatResponse['finish_reason'] { + switch (value) { + case 'stop': + return 'stop'; + case 'tool_calls': + return 'tool_calls'; + case 'length': + return 'length'; + default: + return 'other'; + } +} + +function decodeDataUrl(url: string) { + const match = /^data:([^;,]+);base64,(.+)$/s.exec(url); + if (!match) { + throw new Error('invalid image data URL'); + } + return {contentType: match[1]!, data: Buffer.from(match[2]!, 'base64')}; +} + +function sniffImageContentType(buffer: Buffer) { + if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return 'image/png'; + } + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'image/jpeg'; + } + if (buffer.length >= 12 && buffer.subarray(0, 4).toString() === 'RIFF' && buffer.subarray(8, 12).toString() === 'WEBP') { + return 'image/webp'; + } + if (buffer.length >= 6 && (buffer.subarray(0, 6).toString() === 'GIF87a' || buffer.subarray(0, 6).toString() === 'GIF89a')) { + return 'image/gif'; + } + return 'image/png'; +} + +type OpenAIStreamChunk = { + choices?: Array<{ + delta?: { + content?: string; + tool_calls?: Array<{ + index: number; + id?: string; + function?: {name?: string; arguments?: string}; + }>; + }; + finish_reason?: string; + }>; + usage?: {prompt_tokens?: number; completion_tokens?: number; total_tokens?: number}; +}; + +type ChatUsageLike = { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; +}; + +type AnthropicWireMessage = { + role: 'user' | 'assistant'; + content: Array>; +}; + +function toAnthropicMessages(messages: ChatMessage[]) { + let system = ''; + const wireMessages: AnthropicWireMessage[] = []; + let pendingToolResults: Array> = []; + + const flushToolResults = () => { + if (pendingToolResults.length > 0) { + wireMessages.push({role: 'user', content: pendingToolResults}); + pendingToolResults = []; + } + }; + + for (const message of messages) { + if (message.role === 'system') { + system = [system, message.content ?? ''].filter(Boolean).join('\n\n'); + continue; + } + if (message.role === 'tool') { + pendingToolResults.push({type: 'tool_result', tool_use_id: message.tool_call_id, content: message.content ?? ''}); + continue; + } + flushToolResults(); + if (message.role === 'user') { + wireMessages.push({role: 'user', content: [{type: 'text', text: message.content ?? ''}]}); + continue; + } + const content: Array> = []; + if (message.content) { + content.push({type: 'text', text: message.content}); + } + for (const call of message.tool_calls ?? []) { + content.push({type: 'tool_use', id: call.id, name: call.name, input: call.arguments ?? {}}); + } + if (content.length > 0) { + wireMessages.push({role: 'assistant', content}); + } + } + flushToolResults(); + return {system, wireMessages}; +} + +type AnthropicStreamEvent = { + index?: number; + message?: {usage?: {input_tokens?: number; output_tokens?: number}}; + content_block?: {type: string; id?: string; name?: string; input?: unknown}; + delta?: {type?: string; text?: string; partial_json?: string; stop_reason?: string}; + usage?: {input_tokens?: number; output_tokens?: number}; +}; + +type OpenRouterImageResponse = { + choices?: Array<{ + message?: { + content?: string; + images?: Array<{image_url?: {url?: string}}>; + }; + }>; +}; diff --git a/tui/src/runtime/processBridge.ts b/tui/src/runtime/processBridge.ts deleted file mode 100644 index d77f8ac..0000000 --- a/tui/src/runtime/processBridge.ts +++ /dev/null @@ -1,168 +0,0 @@ -import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {dirname, resolve} from 'node:path'; -import {fileURLToPath} from 'node:url'; -import type {TranscriptKind} from '../state/types.js'; - -export type RuntimeEvent = - | { - type: 'ready'; - status?: 'ready'; - activity?: string; - model?: string; - reasoning?: string; - project?: string; - sessionId?: string; - sessionDir?: string; - provider?: string; - } - | {type: 'status'; status: 'thinking' | 'tool' | 'ready' | 'error'; activity: string} - | {type: 'append'; kind: TranscriptKind; text: string} - | {type: 'usage'; promptTokens?: number; completionTokens?: number; totalTokens?: number} - | {type: 'approval'; activity?: string; detail?: {id?: string; tool?: string; command?: string; description?: string; binary?: string}} - | {type: 'done'} - | {type: 'error'; error: string}; - -export type RuntimeBridge = { - isAvailable(): boolean; - run(text: string): void; - pending(text: string): void; - cancel(): void; - clearHistory(): void; - history(): void; - save(path: string): void; - reload(): void; - approval(id: string, approved: boolean, always: boolean): void; - shutdown(): void; -}; - -type Emit = (event: RuntimeEvent) => void; - -export type RuntimeBridgeOptions = { - resumeId?: string; -}; - -export function createRuntimeBridge(emit: Emit, options: RuntimeBridgeOptions = {}): RuntimeBridge { - let closed = false; - const args = ['runtime-bridge']; - if (options.resumeId) { - args.push(options.resumeId); - } - const child = spawn(resolveRuntimeBinary(), args, { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: process.cwd(), - env: process.env - }); - child.stdin.on('error', error => { - if ((error as NodeJS.ErrnoException).code !== 'EPIPE') { - emit({type: 'append', kind: 'error', text: `runtime bridge stdin error: ${error.message}`}); - } - closed = true; - }); - wireJsonLines(child, emit); - child.stderr.setEncoding('utf8'); - child.stderr.on('data', chunk => { - const text = String(chunk).trim(); - if (text) { - emit({type: 'append', kind: 'error', text}); - } - }); - child.on('error', error => { - emit({type: 'append', kind: 'error', text: `runtime bridge failed: ${error.message}`}); - closed = true; - }); - child.on('exit', (code, signal) => { - closed = true; - if (code && code !== 0) { - emit({type: 'append', kind: 'error', text: `runtime bridge exited with code ${code}`}); - } - if (signal) { - emit({type: 'append', kind: 'error', text: `runtime bridge exited on ${signal}`}); - } - }); - - return { - isAvailable() { - return !closed; - }, - run(text: string) { - send(child, {type: 'run', text}, () => closed); - }, - pending(text: string) { - send(child, {type: 'pending', text}, () => closed); - }, - cancel() { - send(child, {type: 'cancel'}, () => closed); - }, - clearHistory() { - send(child, {type: 'clear'}, () => closed); - }, - history() { - send(child, {type: 'history'}, () => closed); - }, - save(path: string) { - send(child, {type: 'save', text: path}, () => closed); - }, - reload() { - send(child, {type: 'reload'}, () => closed); - }, - approval(id: string, approved: boolean, always: boolean) { - send(child, {type: 'approval', id, approved, always}, () => closed); - }, - shutdown() { - send(child, {type: 'shutdown'}, () => closed); - closed = true; - if (!child.killed) { - child.kill(); - } - } - }; -} - -function wireJsonLines(child: ChildProcessWithoutNullStreams, emit: Emit) { - let buffer = ''; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', chunk => { - buffer += String(chunk); - for (;;) { - const idx = buffer.indexOf('\n'); - if (idx < 0) { - break; - } - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - if (!line) { - continue; - } - try { - emit(JSON.parse(line) as RuntimeEvent); - } catch (error) { - emit({type: 'append', kind: 'error', text: `bad runtime event: ${(error as Error).message}`}); - } - } - }); -} - -function send(child: ChildProcessWithoutNullStreams, payload: unknown, isClosed: () => boolean) { - if (isClosed() || child.killed || child.stdin.destroyed || !child.stdin.writable) { - return; - } - child.stdin.write(`${JSON.stringify(payload)}\n`, error => { - if (error && (error as NodeJS.ErrnoException).code !== 'EPIPE') { - // The stream error event is also wired above; avoid throwing from the - // callback because shutdown is best-effort. - } - }); -} - -function resolveRuntimeBinary() { - if (process.env.OPENMELON_RUNTIME_BIN) { - return process.env.OPENMELON_RUNTIME_BIN; - } - const here = dirname(fileURLToPath(import.meta.url)); - const repoBinary = resolve(here, '..', '..', '..', 'openmelon'); - if (existsSync(repoBinary)) { - return repoBinary; - } - return 'openmelon'; -} diff --git a/tui/src/runtime/protocol.ts b/tui/src/runtime/protocol.ts new file mode 100644 index 0000000..57697d5 --- /dev/null +++ b/tui/src/runtime/protocol.ts @@ -0,0 +1,64 @@ +import type {TranscriptKind} from '../state/types.js'; + +export type RuntimeStatus = 'thinking' | 'tool' | 'ready' | 'error'; + +export type RuntimeEvent = + | { + type: 'ready'; + status?: 'ready'; + activity?: string; + model?: string; + reasoning?: string; + project?: string; + sessionId?: string; + sessionDir?: string; + provider?: string; + clearSession?: boolean; + } + | {type: 'status'; status: RuntimeStatus; activity: string} + | {type: 'append'; kind: TranscriptKind; text: string; delta?: boolean; markdown?: boolean; transient?: boolean} + | {type: 'pending-applied'; texts: string[]} + | {type: 'usage'; promptTokens?: number; completionTokens?: number; totalTokens?: number} + | {type: 'approval'; activity?: string; detail?: ApprovalRequest} + | {type: 'done'} + | {type: 'error'; error: string}; + +export type ApprovalRequest = { + id?: string; + tool?: string; + command?: string; + description?: string; + binary?: string; +}; + +export type RuntimeRequest = + | {type: 'run'; text: string} + | {type: 'pending'; text: string} + | {type: 'cancel'} + | {type: 'clear'} + | {type: 'history'} + | {type: 'save'; text: string} + | {type: 'reload'} + | {type: 'approval'; id: string; approved: boolean; always: boolean} + | {type: 'shutdown'}; + +export type RuntimeEventHandler = (event: RuntimeEvent) => void; + +export type RuntimeClient = { + isAvailable(): boolean; + run(text: string): void; + pending(text: string): void; + unpending(text: string): void; + cancel(): void; + clearHistory(): void; + history(): void; + save(path: string): void; + reload(): void; + approval(id: string, approved: boolean, always: boolean): void; + shutdown(): void; +}; + +export type RuntimeClientOptions = { + resumeId?: string; + initialPrompt?: string; +}; diff --git a/tui/src/runtime/sessionStore.test.ts b/tui/src/runtime/sessionStore.test.ts new file mode 100644 index 0000000..f36396c --- /dev/null +++ b/tui/src/runtime/sessionStore.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, readFile} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {appendMessages, appendPrompt, createNativeSession, setRuntimeInfo, writeSummary} from './sessionStore.js'; + +test('native session store writes Go-compatible session files', async () => { + const workdir = await mkdtemp(path.join(tmpdir(), 'openmelon-session-')); + const session = await createNativeSession(workdir, 'proj', 'test intent', 'old-session'); + await setRuntimeInfo(session, 'openai', 'gpt-5.5'); + await appendPrompt(session, 'user', 'hello'); + await appendMessages(session, [{role: 'user', content: 'hello'}]); + await writeSummary(session, 'done', ['/tmp/a.png'], true); + + const meta = JSON.parse(await readFile(path.join(session.dir, 'meta.json'), 'utf8')) as Record; + assert.equal(meta.project_id, 'proj'); + assert.equal(meta.provider, 'openai'); + assert.equal(meta.model, 'gpt-5.5'); + assert.equal(meta.resumed_from, 'old-session'); + + const messages = await readFile(path.join(session.dir, 'messages.jsonl'), 'utf8'); + assert.equal(JSON.parse(messages.trim()).role, 'user'); + + const summary = JSON.parse(await readFile(path.join(session.dir, 'summary.json'), 'utf8')) as Record; + assert.equal(summary.finished, true); +}); diff --git a/tui/src/runtime/sessionStore.ts b/tui/src/runtime/sessionStore.ts new file mode 100644 index 0000000..3d17659 --- /dev/null +++ b/tui/src/runtime/sessionStore.ts @@ -0,0 +1,109 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; +import {randomBytes} from 'node:crypto'; +import {stateDir} from '../core/project.js'; +import type {ChatMessage} from './nativeTypes.js'; + +export type NativeSession = { + id: string; + dir: string; + startedAt: Date; + workdir: string; + projectId: string; + resumedFrom: string; +}; + +export async function createNativeSession(workdir: string, projectId: string, intent: string, resumedFrom = ''): Promise { + const startedAt = new Date(); + const id = `${sessionTimestamp(startedAt)}-${randomBytes(4).toString('hex')}`; + const dir = path.join(stateDir(workdir), 'sessions', id); + await fs.mkdir(dir, {recursive: true}); + await writeJson(path.join(dir, 'meta.json'), { + version: 2, + id, + project_id: projectId, + intent, + started_at: startedAt.toISOString(), + workspace_root: workdir, + ...(resumedFrom ? {resumed_from: resumedFrom} : {}) + }); + await fs.writeFile(path.join(dir, 'messages.jsonl'), '', {flag: 'a'}); + return {id, dir, startedAt, workdir, projectId, resumedFrom}; +} + +export async function openNativeSession(workdir: string, id: string): Promise { + const dir = path.join(stateDir(workdir), 'sessions', id); + const meta = JSON.parse(await fs.readFile(path.join(dir, 'meta.json'), 'utf8')) as { + project_id?: string; + started_at?: string; + resumed_from?: string; + }; + await fs.writeFile(path.join(dir, 'messages.jsonl'), '', {flag: 'a'}); + return { + id, + dir, + startedAt: meta.started_at ? new Date(meta.started_at) : new Date(), + workdir, + projectId: meta.project_id ?? '', + resumedFrom: meta.resumed_from ?? '' + }; +} + +export async function setRuntimeInfo(session: NativeSession, provider: string, model: string) { + const filePath = path.join(session.dir, 'meta.json'); + const meta = JSON.parse(await fs.readFile(filePath, 'utf8')) as Record; + meta.provider = provider; + meta.model = model; + await writeJson(filePath, meta); +} + +export async function appendPrompt(session: NativeSession, kind: string, content: string) { + const trimmed = content.trim(); + if (!trimmed) { + return; + } + await appendJsonl(path.join(session.dir, 'prompt_history.jsonl'), { + at: new Date().toISOString(), + kind: kind || 'user', + content: trimmed + }); +} + +export async function appendMessages(session: NativeSession, messages: ChatMessage[]) { + for (const message of messages) { + await appendJsonl(path.join(session.dir, 'messages.jsonl'), message); + } +} + +export async function appendEvent(session: NativeSession, type: string, record: Record = {}) { + await appendJsonl(path.join(session.dir, 'events.jsonl'), { + at: new Date().toISOString(), + type, + ...record + }); +} + +export async function writeSummary(session: NativeSession, summary: string, artifacts: string[], finished: boolean) { + await writeJson(path.join(session.dir, 'summary.json'), { + id: session.id, + finished, + summary, + artifacts, + finished_at: new Date().toISOString() + }); +} + +async function appendJsonl(filePath: string, value: unknown) { + await fs.mkdir(path.dirname(filePath), {recursive: true}); + await fs.appendFile(filePath, `${JSON.stringify(value)}\n`); +} + +async function writeJson(filePath: string, value: unknown) { + await fs.mkdir(path.dirname(filePath), {recursive: true}); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function sessionTimestamp(date: Date) { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`; +} diff --git a/tui/src/runtime/webTools.test.ts b/tui/src/runtime/webTools.test.ts new file mode 100644 index 0000000..552635a --- /dev/null +++ b/tui/src/runtime/webTools.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {executeWebFetch, executeWebSearch} from './webTools.js'; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test('web_search extracts DuckDuckGo results and applies domain filters', async () => { + globalThis.fetch = async () => + response( + 'https://duckduckgo.com/html/?q=openmelon', + ` + Example & Result + Useful snippet about OpenMelon. + Blocked Result + Filtered snippet. + ` + ); + + const result = await executeWebSearch( + {query: 'openmelon', allowedDomains: ['example.com'], blockedDomains: ['blocked.test'], maxResults: 8}, + new AbortController().signal + ); + + assert.equal(result.query, 'openmelon'); + assert.equal(result.results.length, 1); + assert.deepEqual(result.results[0], { + title: 'Example & Result', + url: 'https://example.com/post', + snippet: 'Useful snippet about OpenMelon.' + }); +}); + +test('web_fetch normalizes HTML into readable text', async () => { + globalThis.fetch = async () => + response( + 'https://example.com/page', + ` + Hello & Source +

Title

Body & more.

+ `, + {'content-type': 'text/html; charset=utf-8'} + ); + + const result = await executeWebFetch({url: 'https://example.com/page', prompt: 'summarize', maxChars: 500}, new AbortController().signal); + + assert.equal(result.url, 'https://example.com/page'); + assert.equal(result.status, 200); + assert.equal(result.title, 'Hello & Source'); + assert.equal(result.prompt, 'summarize'); + assert.match(result.content, /Title\nBody & more\./); + assert.doesNotMatch(result.content, /ignored|\.x/); + assert.equal(result.truncated, false); +}); + +function response(url: string, body: string, headers: Record = {}) { + return { + url, + status: 200, + statusText: 'OK', + headers: { + get(name: string) { + return headers[name.toLowerCase()] ?? null; + } + }, + async text() { + return body; + } + } as Response; +} diff --git a/tui/src/runtime/webTools.ts b/tui/src/runtime/webTools.ts new file mode 100644 index 0000000..bd915cb --- /dev/null +++ b/tui/src/runtime/webTools.ts @@ -0,0 +1,309 @@ +import type {NativeTool} from './nativeTypes.js'; +import type {NativeRuntimeContext} from './nativeTypes.js'; + +export function webSearchTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'web_search', + description: + 'Search the public web for current information. Returns cited results. Use when facts may be recent, external, or require source attribution.', + parameters: { + type: 'object', + properties: { + query: {type: 'string', minLength: 2}, + allowed_domains: {type: 'array', items: {type: 'string'}}, + blocked_domains: {type: 'array', items: {type: 'string'}}, + max_results: {type: 'number'} + }, + required: ['query'] + } + }, + async dispatch(raw, signal) { + const args = objectArg<{ + query?: string; + allowed_domains?: unknown; + blocked_domains?: unknown; + max_results?: unknown; + }>(raw); + const query = String(args.query ?? '').trim(); + const decision = await ctx.approve({ + id: '', + tool: 'web_search', + command: query, + description: 'Search the public web through DuckDuckGo HTML fallback.', + binary: 'duckduckgo.com' + }); + if (!decision.approved) { + return {error: 'user denied web search'}; + } + return executeWebSearch( + { + query, + allowedDomains: stringList(args.allowed_domains), + blockedDomains: stringList(args.blocked_domains), + maxResults: clampNumber(args.max_results, 8, 1, 12) + }, + signal + ); + } + }; +} + +export function webFetchTool(ctx: NativeRuntimeContext): NativeTool { + return { + spec: { + name: 'web_fetch', + description: 'Fetch a public URL and return readable text with metadata. Use after web_search when a source needs closer inspection.', + parameters: { + type: 'object', + properties: { + url: {type: 'string', format: 'uri'}, + prompt: {type: 'string'}, + max_chars: {type: 'number'} + }, + required: ['url'] + } + }, + async dispatch(raw, signal) { + const args = objectArg<{url?: string; prompt?: string; max_chars?: unknown}>(raw); + const url = String(args.url ?? ''); + const host = safeHost(url) || 'web'; + const decision = await ctx.approve({ + id: '', + tool: 'web_fetch', + command: url, + description: `Fetch and read text content from ${host}.`, + binary: host + }); + if (!decision.approved) { + return {error: 'user denied web fetch'}; + } + return executeWebFetch({url, prompt: String(args.prompt ?? ''), maxChars: clampNumber(args.max_chars, 6000, 500, 20_000)}, signal); + } + }; +} + +type SearchInput = { + query: string; + allowedDomains: string[]; + blockedDomains: string[]; + maxResults: number; +}; + +type FetchInput = { + url: string; + prompt: string; + maxChars: number; +}; + +export async function executeWebSearch(input: SearchInput, signal: AbortSignal) { + if (input.query.length < 2) { + return {error: 'query must be at least 2 characters'}; + } + const started = Date.now(); + const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(input.query)}`; + const response = await fetchWithTimeout(url, signal); + const html = await response.text(); + let results = extractDuckDuckGoResults(html); + if (results.length === 0) { + results = extractGenericLinks(html, response.url); + } + results = results + .filter(result => domainAllowed(result.url, input.allowedDomains, input.blockedDomains)) + .filter((result, index, all) => all.findIndex(item => item.url === result.url) === index) + .slice(0, input.maxResults); + + return { + query: input.query, + source: 'duckduckgo_html', + results, + duration_ms: Date.now() - started, + instructions: results.length > 0 ? 'Use these URLs as sources. Include source links when answering.' : 'No matching web results were found.' + }; +} + +export async function executeWebFetch(input: FetchInput, signal: AbortSignal) { + const requestUrl = normalizeHttpUrl(input.url); + if (!requestUrl) { + return {error: 'url must be an http(s) URL'}; + } + const started = Date.now(); + const response = await fetchWithTimeout(requestUrl, signal); + const contentType = response.headers.get('content-type') ?? ''; + const raw = await response.text(); + const text = normalizeFetchedText(raw, contentType); + const title = extractTitle(raw) || new URL(response.url).hostname; + const content = truncate(text, input.maxChars); + return { + url: response.url, + status: response.status, + status_text: response.statusText, + content_type: contentType, + title, + prompt: input.prompt, + content, + truncated: text.length > content.length, + duration_ms: Date.now() - started + }; +} + +function extractDuckDuckGoResults(html: string) { + const results: Array<{title: string; url: string; snippet: string}> = []; + const blocks = html.match(/]*class="[^"]*result__a[^"]*"[\s\S]*?(?=]*class="[^"]*result__a|<\/body>|$)/gi) ?? []; + for (const block of blocks) { + const anchor = /]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i.exec(block); + if (!anchor) { + continue; + } + const url = decodeDuckDuckGoUrl(decodeHtml(anchor[1] ?? '')); + const title = cleanHtml(anchor[2] ?? ''); + const snippet = cleanHtml(/class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/i.exec(block)?.[1] ?? /class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/div>/i.exec(block)?.[1] ?? ''); + if (url && title) { + results.push({title, url, snippet}); + } + } + return results; +} + +function extractGenericLinks(html: string, baseUrl: string) { + const out: Array<{title: string; url: string; snippet: string}> = []; + for (const match of html.matchAll(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi)) { + const title = cleanHtml(match[2] ?? ''); + const url = absolutizeUrl(decodeHtml(match[1] ?? ''), baseUrl); + if (title && url && /^https?:\/\//.test(url)) { + out.push({title, url, snippet: ''}); + } + } + return out; +} + +function decodeDuckDuckGoUrl(value: string) { + const absolute = absolutizeUrl(value, 'https://duckduckgo.com/'); + if (!absolute) { + return ''; + } + const url = new URL(absolute); + const redirected = url.searchParams.get('uddg'); + return redirected ? decodeURIComponent(redirected) : absolute; +} + +function normalizeFetchedText(raw: string, contentType: string) { + if (!contentType.includes('html')) { + return normalizeText(raw); + } + const text = raw + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<(?:br|hr)\b[^>]*>/gi, '\n') + .replace(/<\/(?:p|div|section|article|header|footer|main|li|h[1-6]|tr|table|ul|ol)\s*>/gi, '\n') + .replace(/<[^>]+>/g, ' '); + return normalizeText(decodeHtml(text)); +} + +function extractTitle(raw: string) { + return cleanHtml(/]*>([\s\S]*?)<\/title>/i.exec(raw)?.[1] ?? ''); +} + +async function fetchWithTimeout(url: string, signal: AbortSignal) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20_000); + signal.addEventListener('abort', () => controller.abort(), {once: true}); + try { + return await fetch(url, { + signal: controller.signal, + headers: { + 'user-agent': 'OpenMelon/0.1 (+https://github.com/eight-acres-lab/openmelon)', + accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.7' + } + }); + } finally { + clearTimeout(timer); + } +} + +function domainAllowed(url: string, allowed: string[], blocked: string[]) { + const host = safeHost(url); + if (!host) { + return false; + } + if (allowed.length > 0 && !allowed.some(domain => hostMatches(host, domain))) { + return false; + } + return !blocked.some(domain => hostMatches(host, domain)); +} + +function hostMatches(host: string, domain: string) { + const normalized = domain.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + return host === normalized || host.endsWith(`.${normalized}`); +} + +function safeHost(url: string) { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return ''; + } +} + +function normalizeHttpUrl(value: string) { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : ''; + } catch { + return ''; + } +} + +function absolutizeUrl(value: string, baseUrl: string) { + try { + return new URL(value, baseUrl).toString(); + } catch { + return ''; + } +} + +function cleanHtml(value: string) { + return decodeHtml(value) + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeText(value: string) { + return value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n') + .map(line => line.replace(/[ \t\f\v]+/g, ' ').trim()) + .filter(Boolean) + .join('\n'); +} + +function decodeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/&#(\d+);/g, (_match, code: string) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16))); +} + +function truncate(value: string, max: number) { + return value.length > max ? `${value.slice(0, max)}…` : value; +} + +function objectArg>(raw: unknown): T { + return raw && typeof raw === 'object' ? (raw as T) : ({} as T); +} + +function stringList(value: unknown) { + return Array.isArray(value) ? value.map(String).map(item => item.trim()).filter(Boolean) : []; +} + +function clampNumber(value: unknown, fallback: number, min: number, max: number) { + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? Math.max(min, Math.min(max, num)) : fallback; +} diff --git a/tui/src/state/inputEditor.ts b/tui/src/state/inputEditor.ts new file mode 100644 index 0000000..fa3c7f4 --- /dev/null +++ b/tui/src/state/inputEditor.ts @@ -0,0 +1,143 @@ +import stringWidth from 'string-width'; +import {wrapBlock} from '../terminal/wrap.js'; + +export type InputEditor = { + text: string; + cursor: number; + preferredColumn: number | null; +}; + +export type WrappedInputLine = { + text: string; + start: number; + end: number; +}; + +export function emptyEditor(): InputEditor { + return {text: '', cursor: 0, preferredColumn: null}; +} + +export function editorWithText(text: string): InputEditor { + return {text, cursor: charLength(text), preferredColumn: null}; +} + +export function insertText(editor: InputEditor, text: string): InputEditor { + const chars = toChars(editor.text); + const insert = toChars(text); + const next = [...chars.slice(0, editor.cursor), ...insert, ...chars.slice(editor.cursor)].join(''); + return {text: next, cursor: editor.cursor + insert.length, preferredColumn: null}; +} + +export function backspace(editor: InputEditor): InputEditor { + if (editor.cursor <= 0) { + return editor; + } + const chars = toChars(editor.text); + const next = [...chars.slice(0, editor.cursor - 1), ...chars.slice(editor.cursor)].join(''); + return {text: next, cursor: editor.cursor - 1, preferredColumn: null}; +} + +export function deleteForward(editor: InputEditor): InputEditor { + const chars = toChars(editor.text); + if (editor.cursor >= chars.length) { + return editor; + } + const next = [...chars.slice(0, editor.cursor), ...chars.slice(editor.cursor + 1)].join(''); + return {text: next, cursor: editor.cursor, preferredColumn: null}; +} + +export function moveCursor(editor: InputEditor, movement: 'left' | 'right' | 'start' | 'end'): InputEditor { + const length = charLength(editor.text); + switch (movement) { + case 'left': + return {...editor, cursor: Math.max(0, editor.cursor - 1), preferredColumn: null}; + case 'right': + return {...editor, cursor: Math.min(length, editor.cursor + 1), preferredColumn: null}; + case 'start': + return {...editor, cursor: 0, preferredColumn: null}; + case 'end': + return {...editor, cursor: length, preferredColumn: null}; + } +} + +export function moveLineBoundary(editor: InputEditor, boundary: 'start' | 'end'): InputEditor { + const chars = toChars(editor.text); + const before = chars.slice(0, editor.cursor); + const after = chars.slice(editor.cursor); + const previousBreak = before.lastIndexOf('\n'); + const nextBreak = after.indexOf('\n'); + const cursor = boundary === 'start' ? previousBreak + 1 : nextBreak < 0 ? chars.length : editor.cursor + nextBreak; + return {...editor, cursor, preferredColumn: null}; +} + +export function moveVertical(editor: InputEditor, direction: -1 | 1, width: number): InputEditor { + const lines = wrapInput(editor.text, width); + const currentIndex = lineIndexForCursor(lines, editor.cursor); + const targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= lines.length) { + return editor; + } + const currentColumn = editor.preferredColumn ?? visualColumn(editor.text, lines[currentIndex]!, editor.cursor); + const targetCursor = cursorAtColumn(editor.text, lines[targetIndex]!, currentColumn); + return {...editor, cursor: targetCursor, preferredColumn: currentColumn}; +} + +export function wrapInput(text: string, width: number): WrappedInputLine[] { + const columns = Math.max(12, width); + if (text.length === 0) { + return [{text: '', start: 0, end: 0}]; + } + + const blocks = text.split('\n'); + const out: WrappedInputLine[] = []; + let offset = 0; + + for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) { + const block = blocks[blockIndex] ?? ''; + if (block.length === 0) { + out.push({text: '', start: offset, end: offset}); + } else { + for (const segment of wrapBlock(block, columns)) { + const length = charLength(segment); + out.push({text: segment, start: offset, end: offset + length}); + offset += length; + } + } + if (blockIndex < blocks.length - 1) { + offset += 1; + } + } + + return out; +} + +export function charLength(text: string) { + return toChars(text).length; +} + +function lineIndexForCursor(lines: WrappedInputLine[], cursor: number) { + const exact = lines.findIndex(line => cursor >= line.start && cursor <= line.end); + return exact >= 0 ? exact : Math.max(0, lines.length - 1); +} + +function visualColumn(text: string, line: WrappedInputLine, cursor: number) { + const chars = toChars(text); + return stringWidth(chars.slice(line.start, Math.min(cursor, line.end)).join('')); +} + +function cursorAtColumn(text: string, line: WrappedInputLine, column: number) { + const chars = toChars(text); + let width = 0; + for (let index = line.start; index < line.end; index++) { + const next = width + stringWidth(chars[index] ?? ''); + if (next > column) { + return index; + } + width = next; + } + return line.end; +} + +function toChars(text: string) { + return Array.from(text); +} diff --git a/tui/src/state/reducer.test.ts b/tui/src/state/reducer.test.ts new file mode 100644 index 0000000..7d2e4f3 --- /dev/null +++ b/tui/src/state/reducer.test.ts @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {initialState, reducer} from './reducer.js'; + +test('assistant deltas merge into one markdown transcript block', () => { + let state = initialState(); + state = reducer(state, {type: 'append-delta', kind: 'assistant', text: '# Title', markdown: true}); + state = reducer(state, {type: 'append-delta', kind: 'assistant', text: '\nBody', markdown: true}); + + assert.equal(state.items.length, 1); + assert.equal(state.items[0]?.text, '# Title\nBody'); + assert.equal(state.items[0]?.markdown, true); +}); + +test('different transcript kinds do not merge', () => { + let state = initialState(); + state = reducer(state, {type: 'append-delta', kind: 'assistant', text: 'hello', markdown: true}); + state = reducer(state, {type: 'append', kind: 'tool', text: '● bash'}); + state = reducer(state, {type: 'append-delta', kind: 'assistant', text: 'world', markdown: true}); + + assert.equal(state.items.length, 3); + assert.equal(state.items[2]?.text, 'world'); +}); + +test('slash command panel does not pollute transcript', () => { + let state = initialState(); + state = reducer(state, {type: 'command-panel', kind: 'info', text: 'Commands'}); + + assert.equal(state.items.length, 0); + assert.equal(state.commandPanel?.text, 'Commands'); + + state = reducer(state, {type: 'insert', text: 'hello'}); + assert.equal(state.commandPanel, null); +}); + +test('pending-applied removes only consumed pending inputs', () => { + let state = initialState(); + state = reducer(state, {type: 'queue-pending', text: 'one'}); + state = reducer(state, {type: 'queue-pending', text: 'two'}); + state = reducer(state, {type: 'pending-applied', count: 1}); + + assert.deepEqual(state.pendingInputs, ['two']); +}); + +test('pending input can be recalled into the editor', () => { + let state = initialState(); + state = reducer(state, {type: 'queue-pending', text: 'first pending'}); + state = reducer(state, {type: 'queue-pending', text: 'second pending'}); + state = reducer(state, {type: 'recall-pending'}); + + assert.equal(state.input, 'first pending\n\nsecond pending'); + assert.equal(state.inputCursor, state.input.length); + assert.deepEqual(state.pendingInputs, []); +}); + +test('ready status clears the running timer', () => { + let state = initialState(); + state = reducer(state, {type: 'turn-started', at: 123}); + state = reducer(state, {type: 'status', status: 'ready', activity: 'Ready'}); + + assert.equal(state.runStartedAt, null); +}); + +test('usage tracks last turn and accumulated totals', () => { + let state = initialState(); + state = reducer(state, {type: 'set-usage', promptTokens: 10, completionTokens: 2}); + state = reducer(state, {type: 'set-usage', promptTokens: 3, completionTokens: 4}); + + assert.equal(state.promptTokens, 3); + assert.equal(state.completionTokens, 4); + assert.equal(state.totalPromptTokens, 13); + assert.equal(state.totalCompletionTokens, 6); +}); + +test('input edits at the cursor instead of appending only', () => { + let state = initialState(); + state = reducer(state, {type: 'insert', text: 'helo'}); + state = reducer(state, {type: 'move-input', movement: 'left'}); + state = reducer(state, {type: 'insert', text: 'l'}); + + assert.equal(state.input, 'hello'); + assert.equal(state.inputCursor, 4); + + state = reducer(state, {type: 'delete-forward'}); + assert.equal(state.input, 'hell'); +}); + +test('input supports line boundary and vertical cursor movement', () => { + let state = initialState(); + state = reducer(state, {type: 'insert', text: 'first\nsecond'}); + state = reducer(state, {type: 'move-input', movement: 'line-start'}); + + assert.equal(state.inputCursor, 'first\n'.length); + + state = reducer(state, {type: 'move-input', movement: 'line-end'}); + assert.equal(state.inputCursor, 'first\nsecond'.length); + + state = reducer(state, {type: 'move-input', movement: 'up', width: 80}); + assert.equal(state.inputCursor, 'first'.length); +}); + +test('history navigation restores cursor to end of selected input', () => { + let state = initialState(); + state = reducer(state, {type: 'insert', text: 'one'}); + state = reducer(state, {type: 'commit-input', text: state.input}); + state = reducer(state, {type: 'insert', text: 'two'}); + state = reducer(state, {type: 'commit-input', text: state.input}); + + state = reducer(state, {type: 'history-prev'}); + assert.equal(state.input, 'two'); + assert.equal(state.inputCursor, 3); + + state = reducer(state, {type: 'history-prev'}); + assert.equal(state.input, 'one'); + assert.equal(state.inputCursor, 3); +}); diff --git a/tui/src/state/reducer.ts b/tui/src/state/reducer.ts index 3b5a67d..1f07d6c 100644 --- a/tui/src/state/reducer.ts +++ b/tui/src/state/reducer.ts @@ -1,9 +1,22 @@ import type {TuiAction, TuiState} from './types.js'; +import { + backspace, + deleteForward, + editorWithText, + insertText, + moveCursor, + moveLineBoundary, + moveVertical, + type InputEditor +} from './inputEditor.js'; export function initialState(): TuiState { return { items: [], + commandPanel: null, input: '', + inputCursor: 0, + inputPreferredColumn: null, inputHistory: [], historyIndex: null, historyDraft: '', @@ -22,6 +35,9 @@ export function initialState(): TuiState { activeSkill: '', promptTokens: 0, completionTokens: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + runStartedAt: null, nextId: 1 }; } @@ -31,19 +47,64 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { case 'append': return { ...state, - items: [...state.items, {id: state.nextId, kind: action.kind, text: action.text}], + commandPanel: null, + items: [...state.items, {id: state.nextId, kind: action.kind, text: action.text, markdown: action.kind === 'assistant'}], nextId: state.nextId + 1 }; - case 'set-input': - return {...state, input: action.input, historyIndex: null, notice: ''}; - case 'insert': - return {...state, input: state.input + action.text, historyIndex: null, notice: ''}; - case 'backspace': + case 'append-delta': { + const last = state.items.at(-1); + if (last && last.kind === action.kind && Boolean(last.markdown) === Boolean(action.markdown)) { + return { + ...state, + commandPanel: null, + items: [ + ...state.items.slice(0, -1), + { + ...last, + text: `${last.text}${action.text}` + } + ] + }; + } return { ...state, - input: Array.from(state.input).slice(0, -1).join(''), - historyIndex: null + commandPanel: null, + items: [ + ...state.items, + {id: state.nextId, kind: action.kind, text: action.text, markdown: action.markdown ?? action.kind === 'assistant'} + ], + nextId: state.nextId + 1 }; + } + case 'command-panel': + return { + ...state, + commandPanel: {id: 0, kind: action.kind, text: action.text, markdown: action.markdown ?? action.kind === 'assistant'} + }; + case 'clear-command-panel': + return {...state, commandPanel: null}; + case 'set-input': + return updateInput(state, editorWithText(action.input), {historyIndex: null, notice: '', commandPanel: null}); + case 'insert': + return updateInput(state, insertText(currentEditor(state), action.text), {historyIndex: null, notice: '', commandPanel: null}); + case 'backspace': + return updateInput(state, backspace(currentEditor(state)), {historyIndex: null}); + case 'delete-forward': + return updateInput(state, deleteForward(currentEditor(state)), {historyIndex: null}); + case 'move-input': { + const editor = currentEditor(state); + const next = + action.movement === 'line-start' + ? moveLineBoundary(editor, 'start') + : action.movement === 'line-end' + ? moveLineBoundary(editor, 'end') + : action.movement === 'up' + ? moveVertical(editor, -1, action.width ?? 80) + : action.movement === 'down' + ? moveVertical(editor, 1, action.width ?? 80) + : moveCursor(editor, action.movement); + return updateInput(state, next); + } case 'clear-input': { const remember = action.remember ?? true; const inputHistory = @@ -53,13 +114,17 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { return { ...state, input: '', + inputCursor: 0, + inputPreferredColumn: null, inputHistory, historyIndex: null, historyDraft: '', paletteIndex: 0, + commandPanel: null, notice: action.notice ?? '' }; } + case 'commit-input': case 'submit-start': { const inputHistory = action.text.trim() !== '' && state.inputHistory.at(-1) !== action.text @@ -67,8 +132,11 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { : state.inputHistory; return { ...state, + commandPanel: null, items: [...state.items, {id: state.nextId, kind: 'user', text: action.text}], input: '', + inputCursor: 0, + inputPreferredColumn: null, inputHistory, historyIndex: null, historyDraft: '', @@ -88,11 +156,11 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { ...state, historyDraft: state.input, historyIndex: next, - input: state.inputHistory[next] ?? state.input + ...inputPatch(editorWithText(state.inputHistory[next] ?? state.input)) }; } const next = Math.max(0, state.historyIndex - 1); - return {...state, historyIndex: next, input: state.inputHistory[next] ?? state.input}; + return {...state, historyIndex: next, ...inputPatch(editorWithText(state.inputHistory[next] ?? state.input))}; } case 'history-next': { if (state.historyIndex === null) { @@ -100,9 +168,9 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { } const next = state.historyIndex + 1; if (next >= state.inputHistory.length) { - return {...state, historyIndex: null, input: state.historyDraft, historyDraft: ''}; + return {...state, historyIndex: null, ...inputPatch(editorWithText(state.historyDraft)), historyDraft: ''}; } - return {...state, historyIndex: next, input: state.inputHistory[next] ?? state.input}; + return {...state, historyIndex: next, ...inputPatch(editorWithText(state.inputHistory[next] ?? state.input))}; } case 'palette-prev': if (action.count <= 0) { @@ -122,13 +190,40 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { case 'queue-pending': return { ...state, + commandPanel: null, pendingInputs: [...state.pendingInputs, action.text], notice: `${state.pendingInputs.length + 1} pending input` }; + case 'pending-applied': { + const count = Math.max(0, action.count); + return { + ...state, + pendingInputs: count >= state.pendingInputs.length ? [] : state.pendingInputs.slice(count) + }; + } + case 'recall-pending': { + const pending = action.texts ?? state.pendingInputs; + if (pending.length === 0 || state.input.trim().length > 0) { + return state; + } + const text = pending.join('\n\n'); + return { + ...state, + ...inputPatch(editorWithText(text)), + commandPanel: null, + pendingInputs: [], + notice: 'pending input recalled' + }; + } case 'drain-pending': return {...state, pendingInputs: []}; case 'status': - return {...state, status: action.status, activity: action.activity ?? state.activity}; + return { + ...state, + status: action.status, + activity: action.activity ?? state.activity, + runStartedAt: action.status === 'ready' || action.status === 'error' ? null : state.runStartedAt + }; case 'notice': return {...state, notice: action.notice}; case 'arm-quit': @@ -137,8 +232,12 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { return { ...state, promptTokens: action.promptTokens, - completionTokens: action.completionTokens + completionTokens: action.completionTokens, + totalPromptTokens: state.totalPromptTokens + action.promptTokens, + totalCompletionTokens: state.totalCompletionTokens + action.completionTokens }; + case 'turn-started': + return {...state, runStartedAt: action.at}; case 'runtime-ready': return { ...state, @@ -146,12 +245,28 @@ export function reducer(state: TuiState, action: TuiAction): TuiState { reasoning: action.reasoning || state.reasoning, project: action.project || state.project, provider: action.provider || state.provider, - sessionId: action.sessionId || state.sessionId, - sessionDir: action.sessionDir || state.sessionDir + sessionId: action.clearSession ? '' : action.sessionId || state.sessionId, + sessionDir: action.clearSession ? '' : action.sessionDir || state.sessionDir }; case 'set-active-skill': - return {...state, activeSkill: action.skill, notice: action.skill ? `skill ${action.skill} applies to next message` : 'skill cleared'}; + return {...state, activeSkill: action.skill, commandPanel: null, notice: action.skill ? `skill ${action.skill} applies to next message` : 'skill cleared'}; case 'clear-transcript': - return {...state, items: [], nextId: 1}; + return {...state, items: [], commandPanel: null, nextId: 1}; } } + +function currentEditor(state: TuiState): InputEditor { + return { + text: state.input, + cursor: Math.min(state.inputCursor, Array.from(state.input).length), + preferredColumn: state.inputPreferredColumn + }; +} + +function inputPatch(editor: InputEditor) { + return {input: editor.text, inputCursor: editor.cursor, inputPreferredColumn: editor.preferredColumn}; +} + +function updateInput(state: TuiState, editor: InputEditor, patch: Partial = {}): TuiState { + return {...state, ...inputPatch(editor), ...patch}; +} diff --git a/tui/src/state/types.ts b/tui/src/state/types.ts index c4bb450..3d6da82 100644 --- a/tui/src/state/types.ts +++ b/tui/src/state/types.ts @@ -4,13 +4,17 @@ export type TranscriptItem = { id: number; kind: TranscriptKind; text: string; + markdown?: boolean; }; export type RuntimeStatus = 'ready' | 'thinking' | 'tool' | 'error'; export type TuiState = { items: TranscriptItem[]; + commandPanel: TranscriptItem | null; input: string; + inputCursor: number; + inputPreferredColumn: number | null; inputHistory: string[]; historyIndex: number | null; historyDraft: string; @@ -29,15 +33,24 @@ export type TuiState = { activeSkill: string; promptTokens: number; completionTokens: number; + totalPromptTokens: number; + totalCompletionTokens: number; + runStartedAt: number | null; nextId: number; }; export type TuiAction = | {type: 'append'; kind: TranscriptKind; text: string} + | {type: 'append-delta'; kind: TranscriptKind; text: string; markdown?: boolean} + | {type: 'command-panel'; kind: TranscriptKind; text: string; markdown?: boolean} + | {type: 'clear-command-panel'} | {type: 'set-input'; input: string} | {type: 'insert'; text: string} | {type: 'backspace'} + | {type: 'delete-forward'} + | {type: 'move-input'; movement: 'left' | 'right' | 'start' | 'end' | 'line-start' | 'line-end' | 'up' | 'down'; width?: number} | {type: 'clear-input'; notice?: string; remember?: boolean} + | {type: 'commit-input'; text: string} | {type: 'submit-start'; text: string} | {type: 'history-prev'} | {type: 'history-next'} @@ -45,11 +58,14 @@ export type TuiAction = | {type: 'palette-next'; count: number} | {type: 'palette-reset'} | {type: 'queue-pending'; text: string} + | {type: 'pending-applied'; count: number} + | {type: 'recall-pending'; texts?: string[]} | {type: 'drain-pending'} | {type: 'status'; status: RuntimeStatus; activity?: string} | {type: 'notice'; notice: string} | {type: 'arm-quit'; at: number} | {type: 'set-usage'; promptTokens: number; completionTokens: number} - | {type: 'runtime-ready'; model?: string; reasoning?: string; project?: string; provider?: string; sessionId?: string; sessionDir?: string} + | {type: 'turn-started'; at: number} + | {type: 'runtime-ready'; model?: string; reasoning?: string; project?: string; provider?: string; sessionId?: string; sessionDir?: string; clearSession?: boolean} | {type: 'set-active-skill'; skill: string} | {type: 'clear-transcript'}; diff --git a/tui/src/terminal/anchoredStdout.ts b/tui/src/terminal/anchoredStdout.ts index 77470c9..119960d 100644 --- a/tui/src/terminal/anchoredStdout.ts +++ b/tui/src/terminal/anchoredStdout.ts @@ -1,4 +1,6 @@ -import {getCursorAnchor} from './cursorAnchor.js'; +import ansiRegex from 'ansi-regex'; +import stringWidth from 'string-width'; +import {cursorAnchorPattern} from './cursorAnchor.js'; const esc = '\u001B['; const cursorLeft = `${esc}G`; @@ -23,7 +25,7 @@ export function createAnchoredStdout(stdout: NodeJS.WriteStream): NodeJS.WriteSt } let anchored = false; - let anchoredRowsToBottom = 0; + let anchorRowFromBottom = 0; const proxy = new Proxy(stdout, { get(target, property, receiver) { @@ -35,20 +37,56 @@ export function createAnchoredStdout(stdout: NodeJS.WriteStream): NodeJS.WriteSt return (chunk: unknown, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void) => { const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : undefined; const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - const text = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : String(chunk); - const restore = anchored ? `${cursorDown(anchoredRowsToBottom)}${cursorLeft}` : ''; - const anchor = getCursorAnchor(); - const place = anchor.active - ? `${cursorShow}${steadyBarCursor}${cursorUp(anchor.rowsToBottom)}${cursorToColumn(anchor.column)}` + const raw = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : String(chunk); + const rendered = extractCursorAnchor(raw); + const restore = anchored ? `${cursorDown(anchorRowFromBottom)}${cursorLeft}` : ''; + const place = rendered.anchor + ? `${cursorShow}${steadyBarCursor}${cursorUp(rendered.anchor.rowFromBottom)}${cursorToColumn(rendered.anchor.column)}` : cursorShow; - anchored = anchor.active; - anchoredRowsToBottom = anchor.rowsToBottom; + anchored = Boolean(rendered.anchor); + anchorRowFromBottom = rendered.anchor?.rowFromBottom ?? 0; - return target.write(`${restore}${text}${place}`, done); + return target.write(`${restore}${rendered.text}${place}`, done); }; } }); return proxy as NodeJS.WriteStream; } + +function extractCursorAnchor(text: string) { + const match = cursorAnchorPattern.exec(text); + if (!match || match.index === undefined) { + return {text}; + } + + const before = text.slice(0, match.index); + const after = text.slice(match.index + match[0].length); + if (match[1] !== undefined && match[2] !== undefined) { + return { + text: `${before}${after}`, + anchor: { + column: Number(match[1]), + rowFromBottom: Number(match[2]) + } + }; + } + const lines = before.split('\n'); + const row = lines.length - 1; + const column = stringWidth(stripAnsi(lines.at(-1) ?? '')); + const totalRows = `${before}${after}`.split('\n').length - 1; + const rowFromBottom = Math.max(0, totalRows - row - 1); + + return { + text: `${before}${after}`, + anchor: { + column, + rowFromBottom + } + }; +} + +function stripAnsi(text: string) { + return text.replace(ansiRegex(), ''); +} diff --git a/tui/src/terminal/cursorAnchor.ts b/tui/src/terminal/cursorAnchor.ts index 07fa346..2fa964e 100644 --- a/tui/src/terminal/cursorAnchor.ts +++ b/tui/src/terminal/cursorAnchor.ts @@ -1,31 +1,22 @@ +const prefix = '\u001B]1337;OpenMelonCursorAnchor'; +const suffix = '\u0007'; + +export const cursorAnchorMarker = `${prefix}${suffix}`; +export const cursorAnchorPattern = /\u001B\]1337;OpenMelonCursorAnchor(?:;(\d+);(\d+))?\u0007/; + export type CursorAnchor = { active: boolean; column: number; - rowsToBottom: number; -}; - -let cursorAnchor: CursorAnchor = { - active: false, - column: 0, - rowsToBottom: 0 + rowFromBottom: number; }; -export function setCursorAnchor(anchor: Omit) { - cursorAnchor = { - active: true, - column: Math.max(0, anchor.column), - rowsToBottom: Math.max(1, anchor.rowsToBottom) - }; +export function markCursorAnchor(anchor?: {column: number; rowFromBottom: number}) { + if (anchor) { + return `${prefix};${Math.max(0, anchor.column)};${Math.max(0, anchor.rowFromBottom)}${suffix}`; + } + return cursorAnchorMarker; } export function clearCursorAnchor() { - cursorAnchor = { - active: false, - column: 0, - rowsToBottom: 0 - }; -} - -export function getCursorAnchor() { - return cursorAnchor; + return; } diff --git a/tui/src/terminal/markdown.test.ts b/tui/src/terminal/markdown.test.ts new file mode 100644 index 0000000..a45218c --- /dev/null +++ b/tui/src/terminal/markdown.test.ts @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {renderMarkdownLines, renderMarkdownPlain} from './markdown.js'; +import {accentColor} from '../theme.js'; + +test('renders common markdown blocks into terminal lines', () => { + const lines = renderMarkdownLines(`# Plan + +- one +1. two +> quote + +\`\`\`ts +const value = 1; +\`\`\` + +| A | B | +|---|---| +| x | y |`); + + assert.deepEqual( + lines.map(line => line.text), + ['Plan', '', '- one', '1. two', '> quote', '', ' ts', ' const value = 1;', '', 'A | B', 'x | y'] + ); +}); + +test('plain markdown removes inline markers and expands links', () => { + assert.equal(renderMarkdownPlain('Use **bold**, `code`, and [OpenMelon](https://example.test).'), 'Use bold, code, and OpenMelon (https://example.test).'); +}); + +test('renders blockquotes as highlighted judgment blocks', () => { + const [line] = renderMarkdownLines('> 作为成熟消费产品,现在还不能下结论。'); + + assert.equal(line?.text, '> 作为成熟消费产品,现在还不能下结论。'); + assert.equal(line?.color, accentColor); + assert.equal(line?.bold, true); +}); + +test('renders lower-level headings as visible section markers', () => { + const lines = renderMarkdownLines('### 第一,agent-native 这个定位比较少见'); + + assert.equal(lines[0]?.text, '第一,agent-native 这个定位比较少见'); + assert.equal(lines[0]?.color, accentColor); + assert.equal(lines[0]?.bold, true); +}); + +test('renders nested and task lists without losing hierarchy', () => { + const lines = renderMarkdownLines(`- top + - child + 1. ordered +- [x] done +- [ ] todo`); + + assert.deepEqual( + lines.map(line => line.text), + ['- top', ' - child', ' 1. ordered', '[x] done', '[ ] todo'] + ); +}); + +test('renders setext headings, tilde fences, images, autolinks, and html as plain terminal text', () => { + const plain = renderMarkdownPlain(`Title +===== + +![alt](image.png) + +inline ~~strike~~ + +~~~json +{"ok": true} +~~~`); + + assert.equal(plain, 'Title\n\nalt (image.png)\nhttps://example.test\ninline strike\n\n json\n {"ok": true}'); +}); diff --git a/tui/src/terminal/markdown.ts b/tui/src/terminal/markdown.ts new file mode 100644 index 0000000..7d315f7 --- /dev/null +++ b/tui/src/terminal/markdown.ts @@ -0,0 +1,217 @@ +import {marked, type Token, type Tokens} from 'marked'; +import {accentColor} from '../theme.js'; + +export type MarkdownLine = { + text: string; + color?: string; + bold?: boolean; +}; + +type RenderContext = { + indent: string; + quote: boolean; +}; + +export function renderMarkdownLines(source: string): MarkdownLine[] { + const tokens = marked.lexer(source.replace(/\r\n/g, '\n')); + const out = renderTokens(tokens, {indent: '', quote: false}); + + while (out.length > 0 && out.at(-1)?.text === '') { + out.pop(); + } + + return out.length > 0 ? out : [{text: ''}]; +} + +export function renderMarkdownPlain(source: string) { + return renderMarkdownLines(source) + .map(line => line.text) + .join('\n') + .trimEnd(); +} + +function renderTokens(tokens: readonly Token[], context: RenderContext): MarkdownLine[] { + const out: MarkdownLine[] = []; + + for (const token of tokens) { + switch (token.type) { + case 'space': + pushBlank(out); + break; + case 'heading': + out.push({ + text: `${context.indent}${context.quote ? '> ' : ''}${inlineText(token.tokens ?? [])}`, + color: context.quote ? accentColor : headingColor(token.depth), + bold: true + }); + break; + case 'paragraph': + out.push({ + text: `${context.indent}${context.quote ? '> ' : ''}${inlineText(token.tokens ?? [])}`, + color: context.quote ? accentColor : 'white', + bold: context.quote || isStrongOnly(token.tokens ?? []) + }); + break; + case 'blockquote': + out.push(...renderTokens(token.tokens ?? [], {...context, quote: true})); + break; + case 'list': + if (isListToken(token)) { + out.push(...renderList(token, context)); + } + break; + case 'code': + if (token.lang) { + out.push({text: `${context.indent} ${token.lang}`, color: 'gray'}); + } + for (const line of token.text.split('\n')) { + out.push({text: `${context.indent} ${line}`, color: 'cyan'}); + } + break; + case 'table': + if (isTableToken(token)) { + out.push(...renderTable(token, context)); + } + break; + case 'hr': + out.push({text: `${context.indent}${'─'.repeat(40)}`, color: 'gray'}); + break; + case 'html': + if (token.text.trim()) { + out.push({text: `${context.indent}${plainText(token.text)}`, color: 'white'}); + } + break; + case 'text': + out.push({text: `${context.indent}${inlineText(token.tokens ?? [token])}`, color: 'white'}); + break; + default: + break; + } + } + + return out; +} + +function renderList(token: Tokens.List, context: RenderContext) { + const out: MarkdownLine[] = []; + const start = typeof token.start === 'number' ? token.start : 1; + + token.items.forEach((item, index) => { + const marker = item.task ? `[${item.checked ? 'x' : ' '}]` : token.ordered ? `${start + index}.` : '-'; + const prefix = `${context.indent}${marker} `; + const textLines = itemText(item); + const color = item.task && item.checked ? 'gray' : 'white'; + + if (textLines.length === 0) { + out.push({text: prefix.trimEnd(), color}); + } else { + textLines.forEach((text, lineIndex) => { + out.push({ + text: `${lineIndex === 0 ? prefix : ' '.repeat(prefix.length)}${text}`, + color, + bold: lineIndex === 0 && isStrongOnly(item.tokens) + }); + }); + } + + for (const child of nestedBlockTokens(item.tokens)) { + out.push(...renderTokens([child], {...context, indent: `${context.indent} `})); + } + }); + + return out; +} + +function itemText(item: Tokens.ListItem) { + const first = item.tokens[0]; + if (first?.type === 'text') { + return inlineText(first.tokens ?? [first]).split('\n'); + } + if (first?.type === 'paragraph') { + return inlineText(first.tokens ?? []).split('\n'); + } + return plainText(item.text).split('\n').filter(Boolean); +} + +function nestedBlockTokens(tokens: Token[]) { + return tokens.filter(token => token.type !== 'text' && token.type !== 'paragraph'); +} + +function renderTable(token: Tokens.Table, context: RenderContext) { + const rows = [token.header, ...token.rows]; + const renderedRows = rows.map(row => row.map(cell => inlineText(cell.tokens ?? []))); + const widths = renderedRows[0]?.map((_, column) => Math.max(...renderedRows.map(row => stringLength(row[column] ?? '')))) ?? []; + const out: MarkdownLine[] = []; + + renderedRows.forEach((row, rowIndex) => { + out.push({ + text: `${context.indent}${row.map((cell, column) => cell.padEnd(widths[column] ?? cell.length)).join(' | ')}`, + color: rowIndex === 0 ? accentColor : 'white', + bold: rowIndex === 0 + }); + }); + + return out; +} + +function inlineText(tokens: readonly Token[]) { + return tokens.map(token => inlineTokenText(token)).join(''); +} + +function inlineTokenText(token: Token): string { + switch (token.type) { + case 'strong': + case 'em': + case 'del': + return inlineText(token.tokens ?? []); + case 'codespan': + return token.text; + case 'br': + return '\n'; + case 'link': { + const label = inlineText(token.tokens ?? []); + return token.href && token.href !== label ? `${label} (${token.href})` : label; + } + case 'image': + return token.href ? `${token.text} (${token.href})` : token.text; + case 'html': + return plainText(token.text); + case 'text': + return token.tokens ? inlineText(token.tokens) : token.text; + case 'escape': + return token.text; + default: + return 'text' in token && typeof token.text === 'string' ? plainText(token.text) : ''; + } +} + +function plainText(value: string) { + return value.replace(/<\/?[a-z][^>]*>/gi, ''); +} + +function isListToken(token: Token): token is Tokens.List { + return token.type === 'list' && 'items' in token && Array.isArray(token.items); +} + +function isTableToken(token: Token): token is Tokens.Table { + return token.type === 'table' && 'header' in token && Array.isArray(token.header) && 'rows' in token && Array.isArray(token.rows); +} + +function headingColor(level: number) { + return level <= 2 ? 'white' : accentColor; +} + +function isStrongOnly(tokens: readonly Token[]) { + const meaningful = tokens.filter(token => token.type !== 'space' && token.raw.trim() !== ''); + return meaningful.length === 1 && meaningful[0]?.type === 'strong'; +} + +function pushBlank(lines: MarkdownLine[]) { + if (lines.length > 0 && lines.at(-1)?.text !== '') { + lines.push({text: ''}); + } +} + +function stringLength(value: string) { + return Array.from(value).length; +}