diff --git a/assets/aether_agent_card.png b/assets/aether_agent_card.png deleted file mode 100644 index 676a2f7..0000000 Binary files a/assets/aether_agent_card.png and /dev/null differ diff --git a/assets/aether_agent_console.html b/assets/aether_agent_console.html deleted file mode 100644 index d2002a4..0000000 --- a/assets/aether_agent_console.html +++ /dev/null @@ -1,80 +0,0 @@ - - -aether-agent — coding session - -
-
- - aether-agent — coding session
-
   ▄▄███▄▄       AETHER CODE
-  ▄█████████▄    v0.1.0
-  ███▄███▄███    [ /model - opus ]  [ /effort MAX ]
-  ▀████▄████▀    /mcp     need to fix aether agent? run /doctor
-    ▀ ▀ ▀ ▀   
-
-  an open-source coding agent for your terminal -- cloud or --local. /help for commands.
-  session: refactor src/auth.ts → async/await + tests · /model opus · /effort MAX
-
-· ==[ SCAN ]== (๑•̀ㅂ•́)و✧✎
-   - mapping imports of src/auth.ts
-   tool_call: read_file
-     - src/auth.ts         142 lines · 4 callbacks
-     - src/session.ts      88 lines
-   * grep_symbol  find all callers before the rewrite
-·   turn 1
-·   status: scan pool 2/9
-   telemetry: 1840 tok
-   [OK] mapped: 2 files, 7 call sites  (remaining=3, stage complete)
- ==[ REASON ]== (๑•̀ㅂ•́)و✧✎
-   - planning the async/await conversion
-   * plan_edit  4 callbacks → async · wrap login() in try/catch
-·     - approach agreement 0.93 → accept
-·   [OK] plan locked: 4 hunks + 1 test file  (remaining=2, plan converged)
- ==[ EDIT ]== (๑•̀ㅂ•́)و✧✎
-   - applying hunks to src/auth.ts
-   tool_call: apply_patch
-   write tests/auth.test.ts  6 cases — login · refresh · expiry · bad-token
-·   checkpoint a91f4c2
-·   [OK] edited 2 files (+118 −74)  (remaining=1, edits applied)
- ==[ VERIFY ]== (๑•̀ㅂ•́)و✧✎
-   - running test command
-   !! 1 failing: expiry not awaited -- patching ( Ò﹏Ó)✎
-   - re-run after the fix
-·   [OK] 6 passed in 0.42s  (remaining=0, green)
-
-  anchoring context _φ(°-°=) local/cache  [ 412.6M / 1.17B tokens ] |███████████░░░░░░░░░░░░░░░░░░░| 35.4%
-
-------------------------------------------------------------------------
-· aether› 
-------------------------------------------------------------------------
-
- \ No newline at end of file diff --git a/assets/aether_agent_console.png b/assets/aether_agent_console.png deleted file mode 100644 index 18a127d..0000000 Binary files a/assets/aether_agent_console.png and /dev/null differ diff --git a/assets/card_commands.png b/assets/card_commands.png deleted file mode 100644 index bcf3f19..0000000 Binary files a/assets/card_commands.png and /dev/null differ diff --git a/assets/card_main.png b/assets/card_main.png deleted file mode 100644 index 417e329..0000000 Binary files a/assets/card_main.png and /dev/null differ diff --git a/assets/card_models.png b/assets/card_models.png deleted file mode 100644 index 36e74cb..0000000 Binary files a/assets/card_models.png and /dev/null differ diff --git a/cleaningnotes.md b/cleaningnotes.md new file mode 100644 index 0000000..e370b1f --- /dev/null +++ b/cleaningnotes.md @@ -0,0 +1,140 @@ +# cleaningnotes.md — 2026-07-01 cleanup pass + +Branch: `chore/repo-cleanup-audit`. Full-repo audit + bounded cleanup of +`aether-agent`. Cross-references [scource.md](scource.md) — file/line +citations below point at locations as surveyed before this pass; see +scource.md §1 for the post-split file map. + +## Method + +3 parallel read-only surveys covering `src/core/` (48 files), `src/ui/` (33 +files), `src/commands/` + root (28 files + root config). Findings +synthesized, then a bounded set of high-value / low-risk fixes applied — +not every finding was acted on; see scource.md §4 for the tracked backlog of +what was found but deliberately left alone (and why). + +Verification after every batch of changes: `npm run build` (tsc, strict +mode) + `npm test` (node:test, 468 tests). Both clean after all changes +below — see "Verification" at the end. + +## Changes made + +### 1. Dead weight removed + +- `docs/index.html` — deleted. 186-line HTML file that was actually a + 1.4MB unused bundler-loading placeholder page (embedded base64 SVG + thumbnail, `__bundler_loading`/`__bundler_thumbnail` DOM ids). Zero + references anywhere in the repo, not part of `package.json`'s `files` + allowlist, only ever touched by the squash-merged PR #32. +- `web-cloud.png` (root, ~57KB) — deleted. Zero references; README embeds + images via `github.com/user-attachments/assets/...` URLs, not local files. +- `assets/` (6 files: `aether_agent_card.png`, `aether_agent_console.html`, + `aether_agent_console.png`, `card_commands.png`, `card_main.png`, + `card_models.png`) — deleted, whole folder. Only reference anywhere is a + historical spec doc (`docs/specs/2026-06-08-aether-agent-rename-design.md`) + pointing at the *pre-rebrand* filename `aether_code_console.html`, which + doesn't even match — confirms these were already orphaned before the + rename. + +Cross-ref: scource.md §1 repo map, §3. + +### 2. Dead code removed + +- `notYet(feature)` — deleted from both `src/commands/vault.ts:45` and + `src/commands/workflow.ts:44`. Byte-for-byte duplicated helper, zero + callers in either file (confirmed via repo-wide grep before deleting). +- `_lastMediaPrompt` — module-private `let` in old `slash.ts:72`, written 5 + times (`photogenSlash`, `reframeSlash`, `videogenSlash`, `animateSlash`, + `recutSlash`) but never read anywhere. Dropped when the media handlers + moved to `slash_media.ts`. + +Cross-ref: scource.md §3. + +### 3. Dead imports removed + +Old `slash.ts` imported 8 symbols that were never referenced in the file +body (confirmed by grep — each name appeared exactly once, in its own +import line): `filterMediaModels`, `ASPECT_RATIOS`, `IMAGE_SHORTCUTS`, +`VIDEO_SHORTCUTS` (from `core/vision.js`), `createTimer`, `HudElementId` +(from `core/hud.js`), `titledBox` (from `ui/box.js`), `goalHelp` (from +`./goals.js`). None were carried into the split files. + +### 4. `fail()`/`errMsg()` duplication consolidated + +5 near-identical error-formatter functions across `media.ts`, `output.ts`, +`vault.ts`, `workflow.ts` (`fail(err)` — print `✗ ` [+ optional login +hint], return 1) and `github.ts` (`errMsg(err)` — return just the message +string, callers compose their own write). Added to `src/core/errors.ts`: + +```ts +export function errorMessage(err: unknown): string +export function fail(err: unknown, hint?: string): number +``` + +Each of the 5 files now imports and calls the shared version — `media.ts`, +`vault.ts`, `workflow.ts` keep a one-line local `fail()` wrapper that +supplies their fixed login hint (output byte-identical to before); +`output.ts` calls `fail(err)` directly (no hint, matches its original +no-hint behavior); `github.ts` uses `errorMessage()` for its one custom- +prefixed message and `fail()`/`fail(err, hint)` for the other three. + +Cross-ref: scource.md §3. The near-duplicate `hashOf`/`hashShortCustody` +pair was found in the same sweep but deliberately **not** merged — see +scource.md §4 for why (subtly different fallback semantics, audit-adjacent +code, not worth the regression risk for a cosmetic dedup). + +### 5. `commands/slash.ts` split (1807 → 522 lines) + +The repo's own convention caps files at ~800 lines (per user-global +coding-style rules already in effect for this session); `slash.ts` was over +2x that. Full read of all 1808 lines, then split into a thin dispatcher + +7 concern files — table and dependency map in scource.md §1. + +Constraints honored: +- `handleSlash`'s switch statement stayed physically in `slash.ts`, + 4-space case indentation unchanged — `test/slash_registry.test.ts` parses + this exact text via regex to verify registry↔switch sync. Verified this + test still passes. +- The 3 symbols `test/slash.test.ts` imports directly + (`resolveSelection`, `handleSlash`, `primeCatalog`) all still exported + from `slash.ts` with identical signatures/behavior. +- Every extracted function moved verbatim (no logic changes) — only + import lines and file boundaries changed. Cross-checked with `git diff` + reasoning during the split, confirmed via full test suite pass after. + +New files: `slash_context.ts`, `slash_git_tools.ts`, `slash_codegen.ts`, +`slash_hud.ts`, `slash_vault_workflow.ts`, `slash_orchestra.ts`, +`slash_media.ts`. Largest is `slash_media.ts` at 303 lines — well under the +repo's ~800-line convention. + +Note left inline in `slash.ts`, not extracted: `/mcp` (dynamic-imports +`./mcp.js`), `/logs`/`/logs-view` (calls `ui/logs_viewer.ts`), and the +`token-budget`/`limit` cases both intentionally route to the same +`limitSlash` — `token-budget` is a documented alias, not a duplicate case +left over from a copy-paste. + +## Verification + +``` +npm run build → clean, 0 errors (tsc --strict) +npm test → 468/468 pass (1 flaky failure on first run, reran clean — + unrelated to this change, see below) +``` + +Note on the flaky test: first `npm test` run showed 467/468 pass with one +unspecified failure that didn't reproduce; immediate rerun was 468/468 clean. +Not chased further since it didn't reproduce and isn't in any file touched +by this pass — flagged here for visibility, not silently ignored. + +## Not done (see scource.md §4 for the full, prioritized list) + +Deliberately out of scope for this pass — found during the survey, logged +with file:line precision in scource.md, left for a dedicated follow-up PR: +cross-file duplication in `ui/` (progress bars, width-padding, ANSI escape +constants, kaomoji maps), a repo-wide magic-number sweep, further god-file +splits (`chat.ts`, `core/vision.ts`, `core/workflow.ts`, `ui/tui_layout.ts`), +and several misplaced-logic items (presentation code in `core/`, business +logic in `commands/`). Reasoning for stopping here: those items either carry +real regression risk for a mechanical pass (audit/custody code, the REPL +input engine) or are high-volume/low-risk items better batched as their own +reviewable PR rather than bundled into this one. diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 0d75c98..0000000 --- a/docs/index.html +++ /dev/null @@ -1,187 +0,0 @@ - - - - - Aether Agent - - - - -
- - - - - - - - - - - AETHER - -
-
Unpacking...
- - - - - - - - - - \ No newline at end of file diff --git a/scource.md b/scource.md new file mode 100644 index 0000000..32f939a --- /dev/null +++ b/scource.md @@ -0,0 +1,204 @@ +# scource.md — aether-agent source of truth + +Dev's table of contents for this repo. Generated during the 2026-07-01 cleanup +pass (branch `chore/repo-cleanup-audit`). Cross-references `cleaningnotes.md` +for what was actually changed. Update this file when structure, canonical +terms, or shared-constant ownership shifts — it's meant to stay current, not +be a one-time snapshot. + +## 1. Repo map + +``` +src/ + main.ts CLI entry — flag parsing, AppContext build, dispatch to cmdX handlers + index.ts Public library entry (@aether/cli) — re-exports client/stream/UI-embed surface + types.ts Shared config/catalog wire types (AetherConfig, CatalogItem, ...) + version.ts VERSION constant + + core/ (48 files, ~6.7k lines) — business logic, network clients, protocol, no terminal I/O + ui/ (33 files, ~3.6k lines) — terminal rendering: ANSI, layout, input, pickers + commands/ (28 files, ~4.7k lines after split) — CLI/REPL command handlers, one file per command surface + +test/ (66 files) — node:test, mirrors src/ by concern (not 1:1 by file) +docs/ — plans/ releases/ reviews/ security/ specs/ (dated design docs, historical) +assets/ — REMOVED 2026-07-01 (was orphaned, see cleaningnotes.md #1) +``` + +### commands/ — slash-command group ownership (post-split) + +`slash.ts` was 1807 lines (repo convention caps files at ~800). Split into a +thin dispatcher + 7 concern files. `handleSlash`'s switch statement stays +physically in `slash.ts` — `test/slash_registry.test.ts` parses its raw +source text (4-space-indented `case "x":` lines) to verify registry↔switch +sync, so that switch must never move. + +| File | Owns | Depends on | +|---|---|---| +| `slash.ts` | dispatcher switch, catalog cache (`_catalog`/`getCatalog`/`primeCatalog`/`byKind`/`resolveSelection`), session handlers (`doctor`, `showPicker`, `select`, `showTier`, `showAudit`, `printHelp`), **plus 3 handlers left inline (not split out — small, single-use):** `/mcp` (dynamic-imports `./mcp.js`), `/logs`/`/logs-view` (calls `ui/logs_viewer.ts`), and a 12-case passthrough block (`/queue /steer /btw /plan /review /recon` etc. — one-line "handled in the REPL" stubs) | all 7 files below | +| `slash_context.ts` | `/pin /drop /snapshot /limit /token-budget /audit-receipt /purge` | `core/context_registry.ts`, `core/custody.ts`, `core/audit.ts` | +| `slash_git_tools.ts` | `/rollback /revert /stage-diff` | `core/stage_diff.ts`, raw `git` via `execSync` | +| `slash_codegen.ts` | `/scaffold /port /test-drive /bench` | `core/scaffold.ts`, `core/port.ts`, `core/test_drive.ts`, `core/bench.ts` | +| `slash_hud.ts` | `/add /hud` | `core/hud.ts`, `core/context_registry.ts` | +| `slash_vault_workflow.ts` | `/vault-* /workflow*` | `core/vault.ts`, `core/workflow.ts` | +| `slash_orchestra.ts` | `/agents /delegate /tree /broadcast /gather` | `core/orchestrator.ts` | +| `slash_media.ts` | `/photogen /frame /re-frame /videogen /sequence /animate /re-cut /output /storyboard` | `core/vision.ts` | + +Other `commands/` files unchanged: `audit.ts`, `auth.ts`, `chat.ts` (725 lines +— second largest in repo, candidate for a future split, see §4), `code.ts`, +`config.ts`, `github.ts`, `goals.ts`, `login.ts`, `mcp.ts`, `media.ts`, +`models.ts`, `output.ts`, `prompt_modes.ts`, `receipt.ts`, `resume.ts`, +`run.ts`, `slash_registry.ts`, `vault.ts`, `workflow.ts`. + +## 2. Canonical terms (things that mean one thing, use consistently) + +- **UVT** — Unified Value Token, the metering unit for the model fleet. Used + everywhere (`bench.ts`, `port.ts`, `scaffold.ts`, `context_registry.ts`, + `transport.ts`, `render.ts`, `hud.ts`) but never spelled out in any single + source file — this doc is now the canonical definition. +- **custody log** (client-held, `core/custody.ts`, + `~/.config/aether/custody.jsonl`) vs **audit trail** (server-side, + `core/audit.ts:fetchTrail`) vs **audit receipt** (`/audit-receipt`, merges + both). See `commands/audit.ts` header comment for the authoritative + explanation — promoted here so it survives file moves. +- **backend** — overloaded. `core/backend.ts`'s `BackendPref`/`BackendPath` + mean "local vs cloud brain routing." Comments elsewhere ("push to the + AETHER-CLOUD backend") mean "the server side of the API" generically. Two + senses, same word — read context. +- **stage** vs **phase** — `stage` (ui/animations.ts, ui/phase_verb.ts) means + agent *activity* (recon/reasoning/execute/self-review/…). `phase` + (ui/goal_chain.ts `GoalPhase`, ui/task_chain.ts, ui/workflow_viewer.ts) + means workflow/goal *phase* (numbered, agent-grouping). Not interchangeable. +- **workspace** vs **worktree** — near-homophones, different meanings. + `core/workspace.ts` = the file-edit sandbox abstraction (`WorkspaceContext`, + `applyEdit`). `core/worktree.ts` = literal `git worktree` isolation. +- **orchestrator** vs **agent** (3 senses) — *orchestrator* = a Neo/Kronus + catalog entry (`CatalogItem.kind === "orchestrator"`). *agent session* = a + live running worker (what `/agents` lists, from `GET /agents`). *`/agent`* + = the slash command that picks an orchestrator. `models.ts:cmdAgents` shows + the static orchestrator catalog; `slash_orchestra.ts:agentsSlash` shows + live running sessions — same English word, unrelated data sources. +- **~/.aether-agent/** (sessions, history, snapshots, repo mirrors, + worktrees) vs **~/.config/aether/** (config, token, mcp.json, custody log, + goals) — two separate per-user data roots, never reconciled into one "my + Aether CLI data" directory. Know which one a given file lives under before + changing path logic. +- **"neo-lite"** — proper-noun personality name (`ui/host_render.ts`), not + self-explanatory from the code alone. + +## 3. Fixed in this pass (2026-07-01) — see cleaningnotes.md for detail + +- Dead weight removed: `docs/index.html` (1.4MB unused bundler placeholder), + `web-cloud.png`, `assets/` (6 files, all orphaned). +- Dead code removed: `notYet()` (duplicated, zero callers, `vault.ts` + + `workflow.ts`), `_lastMediaPrompt` (write-only module state, `slash.ts`). +- Dead imports removed: `filterMediaModels`, `ASPECT_RATIOS`, + `IMAGE_SHORTCUTS`, `VIDEO_SHORTCUTS`, `createTimer`, `titledBox`, + `goalHelp`, `HudElementId` (all imported, never used, in old `slash.ts`); + plus (found by the adversarial reviewer via `tsc --noUnusedLocals`) + `mediaKind`/`listOutput`/`findOutput`/`clearOutput` in `media.ts`, + `TemplateInfo`/`WorkflowAssessResponse` in `commands/workflow.ts`, and + `VaultSpacesFile` in `core/workflow.ts`. +- Duplication consolidated: `fail()`/`errMsg()` error-formatter (5 near- + identical copies across `media.ts`, `output.ts`, `vault.ts`, `workflow.ts`, + `github.ts`) → `core/errors.ts` exports `errorMessage()` + `fail()`. +- God-file split: `commands/slash.ts` 1807 → 522 lines + 7 new focused files + (table in §1). No file in the split exceeds ~300 lines. + +## 4. Backlog — found, not fixed this pass (real follow-up work) + +Not touched because: lower value-to-risk ratio than the items above, or the +fix is itself a nontrivial refactor that deserves its own reviewed PR rather +than being bundled into a mechanical cleanup pass. Grouped by file/area so a +future pass can grab one and go. + +**Duplication (cross-file, medium risk to unify):** +- `hashOf` (`commands/audit.ts:69`) and `hashShortCustody` + (`commands/slash_context.ts`) are near-identical but not byte-identical + (different null/empty-string fallback, `hashOf` checks 4 fields, + `hashShortCustody` checks 3). Audit/custody-adjacent — unify only with + test coverage proving output is unchanged for both callers. +- Poll-until-deadline loop duplicated 3x: `github.ts:pollUntilConnected`, + `mcp.ts:McpClient.pollUntilConnected`, `core/device.ts:pollForToken`. + `PollOpts { intervalSec, timeoutSec }` type also duplicated verbatim in + `github.ts` + `mcp.ts`. +- 4 independent progress/fill-bar implementations in `ui/`: `progress.ts` + (canonical), `ui/statusbar.ts` inline, `ui/status_renderer.ts:bar()`, + `ui/tui_layout.ts:bar()`. +- "Pad string to visible width" reimplemented in ≥5 `ui/` files despite + `ui/text.ts` claiming sole ownership of width math. +- ANSI alt-screen/cursor escape codes (`\x1b[?1049h` etc.) hand-defined + independently in `ui/model_picker.ts`, `ui/tui_layout.ts`, + `ui/status_renderer.ts`, `ui/logs_viewer.ts` — `ui/restore.ts` claims to be + the "central terminal-restore registry" but doesn't own these constants. +- Kaomoji/stage-glyph vocabulary duplicated across 4 maps with overlapping + keys: `ui/kaomoji.ts`, `ui/phase_verb.ts`, `ui/statusbar.ts`, + `ui/host_render.ts`. +- JSON-file-read-with-fallback pattern (`try { JSON.parse(readFileSync) } + catch { default }`) reimplemented independently 6+ times across + `core/config.ts`, `core/goals.ts`, `core/mcp_store.ts`, + `core/context_registry.ts`, `core/session_resume.ts`, `core/vision.ts`. + +**More dead imports (found via `tsc --noUnusedLocals` during the adversarial +review pass) — fixed since the files were already open in this diff:** +`commands/media.ts` (`mediaKind`, `listOutput`, `findOutput`, `clearOutput`), +`commands/workflow.ts` (`TemplateInfo`, `WorkflowAssessResponse` types), +`core/workflow.ts` (`VaultSpacesFile` type). See cleaningnotes.md §6. + +**Still backlog:** +- Repo-wide: dozens of unused `ctx: AppContext` parameters on handler + functions that don't need it (`tsc --noUnusedLocals --noUnusedParameters` + surfaces them all) — this is a consistent enough pattern across the + codebase that it reads as an intentional "handlers share one signature + shape" convention rather than accidental dead params; worth a deliberate + decision (enable `noUnusedParameters` + fix, or leave as house style) + rather than a silent mass-edit. + +**Magic numbers/strings (low risk, high volume — batch as its own PR):** +- Timeouts: no shared constant across `ollama.ts`, `web.ts`, + `stage_diff.ts`, `test_drive.ts`, `tool_executor.ts`, `agent_events.ts`. +- "Keep last N records" caps: `HISTORY_CAP=1000`, custody `MAX=500`, + vision output `>100` — three unrelated magic numbers for the same + "capped append log" concept. +- `~/.aether-agent` root path constructed independently in 5 files instead + of one shared helper (contrast `core/config.ts`'s `configDir()`, which + does this correctly). +- Model-key literals (`"vision_gpt_image2"`, `"vision_seedance"`) repeated + 7x across the old `slash.ts` (now split across `slash_media.ts`) instead + of named constants in `core/vision.ts`. + +**Oversized files (candidates for a future split, same pattern as §1):** +- `commands/chat.ts` (725 lines) — `repl()` alone is ~455 lines mixing raw- + mode lifecycle, paste state machine, Ctrl-C handling, slash dispatch, and + HUD repaint. Candidate: extract the REPL engine to `ui/repl.ts`. +- `core/vision.ts` (428 lines) — model routing + prompt building + download + engine + output log + storyboard parser in one file. +- `core/workflow.ts` (418 lines) — ~165 lines are inline literal template + *data* (8 hardcoded workflow graphs) that belongs in a data file, not + logic code. +- `ui/tui_layout.ts` (363 lines) — `TuiLayout` class is a god-object + (terminal lifecycle + pager state + 4 render methods + resize handling). +- `commands/code.ts:cmdCode()` (200 lines, lines 106-304) — 6+ + responsibilities in one function; the animated/plain render branches + duplicate structure. + +**Misplaced logic (presentation code sitting in core/, or vice versa):** +- `core/hud.ts` and `core/render.ts` import from `src/ui/` and contain ANSI + rendering — arguably belong in `ui/`, not `core/`. +- `commands/goals.ts:decomposeGoal` (pure heuristic, zero I/O) belongs in + `core/goals.ts` alongside the rest of the goal model. +- `commands/auth.ts:renderAuthBox`/`renderLoggedOut` (~100 lines of ASCII-art + box rendering) belongs in `ui/`. +- `ui/logs_viewer.ts:loadAllSessions`/`exportSession` do filesystem + read/write — data-access logic sitting in a `ui/` file. + +## 5. Number/constant relations worth knowing before touching them + +- `TOKENS_PER_GB = 233_000_000` (`ui/statusbar.ts`) — named, exported, but + the number itself is undocumented anywhere. Don't "simplify" it without + understanding where 233M comes from. +- `CTRL_C_WINDOW_MS = 1500` (`commands/chat.ts`) and `heartbeatTimeoutMs: + 5000` (`commands/code.ts`) are the *only* named UX-timing constants in + `commands/` — everything else in the backlog above is a bare literal. +- Box widths are inconsistent per-file: `BOX_W = 64` (`auth.ts`), `BOX = 74` + (`slash.ts`), inline `{ width: 60 }` (`slash_context.ts`) — three + unrelated hardcoded panel widths, no shared layout constant exists yet. diff --git a/src/commands/github.ts b/src/commands/github.ts index 15a67a9..919ad5b 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -8,6 +8,7 @@ import type { AppContext } from "../core/context.js"; import { openBrowser } from "../core/browser.js"; +import { fail as coreFail, errorMessage } from "../core/errors.js"; import { getGithubStatus, startGithubConnect, @@ -63,8 +64,7 @@ async function githubStatus(ctx: AppContext): Promise { process.stdout.write(renderStatus(s)); return s.connected ? 0 : 1; } catch (err) { - process.stderr.write(`✗ ${errMsg(err)}\n (are you logged in? run: aether auth login)\n`); - return 1; + return coreFail(err, "are you logged in? run: aether auth login"); } } @@ -73,7 +73,7 @@ async function githubConnect(ctx: AppContext, opts: GithubOpts): Promise try { installUrl = await startGithubConnect(ctx.api); } catch (err) { - process.stderr.write(`✗ could not start GitHub connect: ${errMsg(err)}\n`); + process.stderr.write(`✗ could not start GitHub connect: ${errorMessage(err)}\n`); return 1; } process.stdout.write(`\nTo link GitHub, open:\n ${installUrl}\n\n`); @@ -85,8 +85,7 @@ async function githubConnect(ctx: AppContext, opts: GithubOpts): Promise process.stdout.write(`✓ GitHub linked${s.login ? ` (${s.login})` : ""}.\n`); return 0; } catch (err) { - process.stderr.write(`✗ ${errMsg(err)}\n`); - return 1; + return coreFail(err); } } @@ -96,11 +95,6 @@ async function githubDisconnect(ctx: AppContext): Promise { process.stdout.write("GitHub unlinked.\n"); return 0; } catch (err) { - process.stderr.write(`✗ ${errMsg(err)}\n`); - return 1; + return coreFail(err); } } - -function errMsg(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} diff --git a/src/commands/media.ts b/src/commands/media.ts index 2ad93c2..b3a8318 100644 --- a/src/commands/media.ts +++ b/src/commands/media.ts @@ -7,10 +7,11 @@ import type { AppContext } from "../core/context.js"; import type { CatalogResponse } from "../types.js"; import { MODELS_PATH } from "../core/transport.js"; +import { fail as coreFail } from "../core/errors.js"; import { - filterMediaModels, resolveModelKey, autoRouteModel, mediaKind, + filterMediaModels, resolveModelKey, autoRouteModel, buildMediaPrompt, dispatchGeneration, downloadMediaFile, - ensureOutputDir, recordOutput, listOutput, findOutput, openOutput, clearOutput, + ensureOutputDir, recordOutput, openOutput, IMAGE_SHORTCUTS, VIDEO_SHORTCUTS, type MediaKind, type MediaModel, type GenFlags, type GenResult, } from "../core/vision.js"; @@ -235,7 +236,5 @@ function printMediaHelp(kind: MediaKind): void { } function fail(err: unknown): number { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`✗ ${msg}\n (are you logged in? run: aether auth login)\n`); - return 1; + return coreFail(err, "are you logged in? run: aether auth login"); } diff --git a/src/commands/output.ts b/src/commands/output.ts index f2ca212..47321c4 100644 --- a/src/commands/output.ts +++ b/src/commands/output.ts @@ -5,6 +5,7 @@ import type { AppContext } from "../core/context.js"; import { listOutput, findOutput, openOutput, clearOutput } from "../core/vision.js"; import { theme } from "../ui/theme.js"; +import { fail } from "../core/errors.js"; export async function cmdOutput(ctx: AppContext, argv: string[]): Promise { const sub = (argv[0] ?? "").toLowerCase(); @@ -50,8 +51,3 @@ async function outputClean(): Promise { return 0; } -function fail(err: unknown): number { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`✗ ${msg}\n`); - return 1; -} diff --git a/src/commands/slash.ts b/src/commands/slash.ts index e7766ba..d1ab41d 100644 --- a/src/commands/slash.ts +++ b/src/commands/slash.ts @@ -12,49 +12,42 @@ // /audit [n] recent Aether audit trail // /clear clear the screen // /exit | /quit leave the REPL +// +// Command handlers live in sibling slash_*.ts files, grouped by concern +// (context, git tools, codegen, HUD, vault/workflow, orchestra, media) to +// keep each file under the repo's ~800-line convention. This file owns the +// dispatcher switch, the models/orchestrators catalog cache, and the +// session-level handlers (picker, doctor, help) that need direct access to +// that cache. import type { Writable } from "node:stream"; import type { AppContext } from "../core/context.js"; import type { CatalogItem, CatalogResponse } from "../types.js"; import { MODELS_PATH } from "../core/transport.js"; -import { AGENTS_PATH } from "../core/transport.js"; import { fetchTrail } from "../core/audit.js"; import { isApiToken } from "./auth.js"; -import { getVaultSnapshot, searchNotes, notesByTag, getNotesTree } from "../core/vault.js"; -import { WORKFLOW_TEMPLATES, listWorkflows } from "../core/workflow.js"; import { theme } from "../ui/theme.js"; import { SLASH_COMMANDS, SLASH_SECTIONS, findCommand, suggestCommand } from "./slash_registry.js"; -import { handleGoal, handleGoals, goalHelp } from "./goals.js"; -import { box, titledBox } from "../ui/box.js"; +import { handleGoal, handleGoals } from "./goals.js"; +import { box } from "../ui/box.js"; import { sliceVisible } from "../ui/text.js"; import { pickModel } from "../ui/model_picker.js"; import { runLogsViewer } from "../ui/logs_viewer.js"; -import { getRegistry, resetRegistry, saveSnapshot, loadSnapshot, listSnapshots, ContextRegistry, syncToBackend, loadFromBackend } from "../core/context_registry.js"; -import { readCustodyLog } from "../core/custody.js"; -import type { AuditEntry } from "../core/audit.js"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { delegateWorker, getOrchTree, broadcastToAgents, gatherResults, requireOrchestrator } from "../core/orchestrator.js"; + +import { pinSlash, dropSlash, snapshotSlash, limitSlash, auditReceiptSlash, purgeSlash } from "./slash_context.js"; +import { rollbackSlash, revertSlash, stageDiffSlash } from "./slash_git_tools.js"; +import { scaffoldSlash, portSlash, testDriveSlash, benchSlash } from "./slash_codegen.js"; +import { addSlash, hudSlash } from "./slash_hud.js"; import { - filterMediaModels, resolveModelKey, autoRouteModel, - buildMediaPrompt, dispatchGeneration, downloadMediaFile, - ensureOutputDir, recordOutput, listOutput, findOutput, openOutput, clearOutput, - ASPECT_RATIOS, IMAGE_SHORTCUTS, VIDEO_SHORTCUTS, - parseStoryboard, saveStoryboard, loadStoryboard, listStoryboards, - type MediaKind, type GenFlags, type GenResult, -} from "../core/vision.js"; -import { basename } from "node:path"; -import { existsSync as fsExistsSync } from "node:fs"; -import { generateScaffold, isValidScaffoldType, SCAFFOLD_USAGE, type ScaffoldType } from "../core/scaffold.js"; -import { portCode, readSource, writePortedFiles } from "../core/port.js"; -import { generateDiff } from "../core/stage_diff.js"; -import { startTestDrive } from "../core/test_drive.js"; -import { runBenchmark } from "../core/bench.js"; + vaultStatusSlash, vaultContextSlash, vaultSearchSlash, vaultRecentSlash, + vaultProjectSlash, vaultTagSlash, vaultTreeSlash, + workflowSlash, workflowTemplatesSlash, workflowTemplateSlash, +} from "./slash_vault_workflow.js"; +import { agentsSlash, delegateSlash, treeSlash, broadcastSlash, gatherSlash } from "./slash_orchestra.js"; import { - HUD_ELEMENTS, resolveHudElement, - renderHud, timerLive, createTimer, - type HudElementId, type HudRenderState, -} from "../core/hud.js"; + photogenSlash, reframeSlash, videogenSlash, animateSlash, recutSlash, + outputSlash, storyboardSlash, +} from "./slash_media.js"; export interface SlashResult { exit: boolean; @@ -65,13 +58,6 @@ export interface SlashResult { type Kind = "model" | "orchestrator"; -// Media pipeline state — persists across turns in the same REPL session. -// Cleared on REPL restart. Used by /re-frame and /re-cut. -let _lastMediaUrl: string | null = null; -let _lastMediaModel: string | null = null; -let _lastMediaPrompt: string | null = null; -let _lastMediaKind: MediaKind | null = null; - // Catalog is cached per REPL session; a fresh session re-fetches. let _catalog: CatalogResponse | null = null; @@ -300,9 +286,7 @@ export async function handleSlash( await snapshotSlash(ctx, out, arg); break; case "limit": - await limitSlash(ctx, out, arg); - break; - case "token-budget": + case "token-budget": // alias — same handler, not a leftover duplicate await limitSlash(ctx, out, arg); break; case "audit-receipt": @@ -352,519 +336,6 @@ export async function handleSlash( return { exit: false }; } -// ── /pin ────────────────────────────────────── - -async function pinSlash(ctx: AppContext, out: Writable, arg: string, _line: string): Promise { - if (!arg.trim() || arg.trim() === "list" || arg.trim() === "ls") { - const pins = getRegistry().pins; - if (pins.length === 0) { - out.write("(no pinned files — use /pin [reason] to pin one)\n"); - return; - } - out.write(theme.cyan("📌 Pinned Context\n")); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - for (const p of pins) { - out.write(` ${theme.bold(p.label)} ${theme.dim(p.path)} ${theme.muted(p.reason)}\n`); - } - return; - } - - const parts = arg.trim().split(/\s+/); - const pth = parts[0]!; - const reason = parts.slice(1).join(" ") || "pinned"; - - const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth); - const label = pth.split("/").pop() || pth; - - const entry = getRegistry().pin(resolved, label, reason); - out.write(`${theme.cyan("📌 pinned")} ${theme.bold(entry.label)} ${theme.dim(entry.path)} (${entry.reason})\n`); - out.write(theme.dim(" This file will persist in context across /recon and /autonomous-execution loops.\n")); - syncAfter(ctx); -} - -// ── /drop ───────────────────────────────────── - -async function dropSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!arg.trim()) { - const registry = getRegistry(); - out.write("usage: /drop evict a file from memory context\n"); - out.write(" /drop src/core/old.ts\n"); - if (registry.pins.length > 0) { - out.write("\n Pinned files (use /pin list for details):\n"); - for (const p of registry.pins) { - out.write(` ${theme.dim(p.label)}\n`); - } - } - if (registry.drops.length > 0) { - out.write("\n Recently dropped:\n"); - for (const d of registry.drops.slice(-5)) { - out.write(` ${theme.dim(d)}\n`); - } - } - return; - } - - const pth = arg.trim(); - const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth); - const wasPinned = getRegistry().isPinned(resolved); - getRegistry().drop(resolved); - - if (wasPinned) { - out.write(`${theme.cyan("🗑 dropped")} ${theme.bold(pth)} — removed from pinned context\n`); - } else { - out.write(`${theme.cyan("🗑 evicted")} ${theme.dim(pth)}\n`); - out.write(theme.dim(" (wasn't pinned, but will be excluded from future context loads)\n")); - } - syncAfter(ctx); -} - -// ── /snapshot ───────────────────────────────── - -async function snapshotSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const registry = getRegistry(); - const sub = arg.trim().split(/\s+/)[0]?.toLowerCase() ?? ""; - - if (sub === "resume" || sub === "load") { - const id = arg.trim().split(/\s+/).slice(1).join(" "); - if (!id) { - // Try cloud backend first - const cloudLoaded = await loadFromBackend(ctx.api); - if (cloudLoaded) { - const reg = getRegistry(); - out.write(`${theme.cyan("☁ loaded from cloud")} ${theme.bold(reg.sessionLabel)} (${reg.pins.length} pins, cap ${reg.uvtCap ?? "none"})\n`); - out.write(theme.dim(" Use /pin list to see restored context.\n")); - return; - } - const snaps = listSnapshots(); - if (snaps.length === 0) { - out.write("(no snapshots — use /snapshot to save one)\n"); - return; - } - out.write(theme.cyan("💾 Saved Snapshots\n")); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - for (const s of snaps) { - const pinCnt = s.data.pins.length; - const cap = s.data.uvtCap ? ` cap ${s.data.uvtCap} UVT` : ""; - out.write(` ${theme.bold(s.id)} ${theme.dim(s.data.createdAt)} ${s.data.sessionLabel} (${pinCnt} pins${cap})\n`); - } - out.write(theme.dim("\n /snapshot resume to restore a snapshot\n")); - return; - } - const data = loadSnapshot(id); - if (!data) { - out.write(`no snapshot: ${id} (use /snapshot to list)\n`); - return; - } - const restored = ContextRegistry.fromSnapshot(data); - resetRegistry(); - Object.assign(getRegistry(), restored); - out.write(`${theme.cyan("📂 restored")} ${theme.bold(data.sessionLabel)} (${data.pins.length} pins, cap ${data.uvtCap ?? "none"})\n`); - out.write(` cwd: ${data.cwd}\n`); - out.write(theme.dim(" Use /pin list to see restored context.\n")); - return; - } - - if (sub === "list" || sub === "ls") { - const snaps = listSnapshots(); - if (snaps.length === 0) { - out.write("(no snapshots)\n"); - return; - } - out.write(theme.cyan("💾 Snapshots\n")); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - for (const s of snaps) { - const pinCnt = s.data.pins.length; - out.write(` ${theme.bold(s.data.sessionLabel)} ${theme.dim(s.data.createdAt)} (${pinCnt} pins)\n`); - } - return; - } - - const snapPath = saveSnapshot(registry); - const basename = snapPath.split("/").pop() || snapPath; - out.write(`${theme.cyan("💾 snapshot saved")} ${theme.bold(basename)}\n`); - out.write(` ${theme.dim(snapPath)}\n`); - out.write(` pins: ${registry.pins.length} UVT cap: ${registry.uvtCap ?? "none"} drops: ${registry.drops.length}\n`); - out.write(theme.dim(" Resume with: /snapshot resume \n")); - syncAfter(ctx); -} - -// ── /limit ──────────────────────────────────── - -/** Fire-and-forget backend sync. Never blocks the REPL. */ -function syncAfter(ctx: AppContext): void { - void syncToBackend(ctx.api).catch(() => {}); -} - -function renderUvtBar(pct: number, width: number): string { - const filled = Math.round((pct / 100) * width); - const empty = width - filled; - const color = pct > 80 ? theme.muted : pct > 50 ? theme.dim : theme.cyan; - return color("[" + "█".repeat(filled) + "░".repeat(empty) + "]"); -} - -async function limitSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const registry = getRegistry(); - - if (!arg.trim()) { - const current = registry.uvtCap; - const spent = registry.uvtSpent; - if (current == null) { - out.write("UVT cap: none (uncapped)\n"); - } else { - const remaining = Math.max(0, current - spent); - const pct = current > 0 ? Math.round((spent / current) * 100) : 0; - const bar = renderUvtBar(pct, 20); - out.write(`UVT cap: ${theme.bold(String(current))} spent: ${spent} remaining: ${remaining} ${bar}\n`); - } - out.write(theme.dim(" /limit set cap (e.g., /limit 50000)\n")); - out.write(theme.dim(" /limit off remove cap\n")); - return; - } - - if (arg.trim().toLowerCase() === "off" || arg.trim().toLowerCase() === "none") { - registry.uvtCap = null; - out.write(theme.cyan("UVT cap removed — session is uncapped.\n")); - return; - } - - const n = Number(arg.trim()); - if (!Number.isFinite(n) || n <= 0) { - out.write(`invalid: ${arg} — use a positive number (e.g., /limit 50000)\n`); - return; - } - - registry.setUvtCap(Math.floor(n)); - out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))} — agent will pause and ask permission if ceiling hit\n`); - syncAfter(ctx); -} - -// ── /audit-receipt ──────────────────────────── - -function hashShortCustody(v: unknown): string | null { - if (v == null) return null; - if (typeof v === "string") return v.slice(0, 12); - if (typeof v === "object") { - const o = v as Record; - const inner = o["hash"] ?? o["env_hash"] ?? o["commitment_hash"] ?? o["digest"]; - if (inner != null) return String(inner).slice(0, 12); - } - return "\u2713"; -} - -async function auditReceiptSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const nArg = Number(arg); - const limit = Number.isInteger(nArg) && nArg > 0 ? Math.min(nArg, 100) : 20; - - const custody = readCustodyLog(limit); - let serverEntries: AuditEntry[] = []; - try { - serverEntries = await fetchTrail(ctx.api, { limit }); - } catch { - /* offline — local custody is enough */ - } - - out.write(theme.cyan("🧾 Audit Receipt\n")); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - - const H_TIME = 26; - const H_ORDER = 14; - const H_EVENT = 14; - const H_COMMIT = 14; - const H_PATH = 20; - - const header = - "time".padEnd(H_TIME) + - "order_id".padEnd(H_ORDER) + - "event".padEnd(H_EVENT) + - "commitment".padEnd(H_COMMIT) + - "path"; - out.write(theme.bold(" " + header) + "\n"); - out.write(theme.dim(" " + "─".repeat(H_TIME + H_ORDER + H_EVENT + H_COMMIT + H_PATH)) + "\n"); - - for (const c of custody) { - const ts = c.received_at != null ? new Date(c.received_at).toISOString().padEnd(H_TIME) : "—".padEnd(H_TIME); - const oid = (String(c.order_id ?? "—")).slice(0, H_ORDER - 1).padEnd(H_ORDER); - const evt = "chat_turn".padEnd(H_EVENT); - const comm = (hashShortCustody(c.commitment) ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT); - const pathCol = String(c.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH); - out.write(` ${theme.dim(ts)}${oid}${theme.cyan(evt)}${theme.dim(comm)}${pathCol}\n`); - } - - for (const e of serverEntries) { - const ts = String(e.timestamp).padEnd(H_TIME); - const oid = e.orderId.slice(0, H_ORDER - 1).padEnd(H_ORDER); - const evt = e.eventType.padEnd(H_EVENT); - const comm = (e.commitmentHash ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT); - const pathCol = (e.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH); - out.write(` ${theme.dim(ts)}${theme.dim(oid)}${theme.muted(evt)}${theme.dim(comm)}${pathCol}\n`); - } - - const totalEntries = custody.length + serverEntries.length; - const uvtTotal = getRegistry().uvtSpent; - const boxContent = [ - "", - ` Total entries: ${totalEntries}`, - ` UVT spent: ${uvtTotal}`, - ` UVT cap: ${getRegistry().uvtCap ?? "none"}`, - "", - " Export proof: aether receipt ", - " Full log: /logs-view", - "", - ]; - out.write("\n" + box(boxContent, { width: 60 }) + "\n"); -} - -// ── /rollback ───────────────────────────────── - -async function rollbackSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const n = parseInt(arg.trim()) || 1; - if (n < 1 || n > 50) { - out.write("usage: /rollback [n] revert last n filesystem changes (1-50, default 1)\n"); - return; - } - - const cwd = process.cwd(); - const gitDir = join(cwd, ".git"); - if (!existsSync(gitDir)) { - out.write(theme.muted("Not in a git repository. /rollback requires git for safe undo.\n")); - return; - } - - const { execSync } = require("node:child_process") as typeof import("node:child_process"); - try { - const status = execSync("git diff --name-only", { cwd, encoding: "utf8", timeout: 5000 }); - const dirty = status.trim().split("\n").filter(Boolean); - if (dirty.length === 0) { - out.write("(working tree clean — nothing to rollback)\n"); - return; - } - - out.write(`${theme.cyan("\u21A9 Ready to rollback")} ${dirty.length} files changed\n`); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - const show = dirty.slice(0, 20); - for (const f of show) { - out.write(` ${theme.muted(f)}\n`); - } - if (dirty.length > 20) { - out.write(` ${theme.dim(`... and ${dirty.length - 20} more`)}\n`); - } - - const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert all ${dirty.length} uncommitted changes? [y/N] `)); - if (!ok) { - out.write("cancelled.\n"); - return; - } - - execSync("git checkout -- .", { cwd, encoding: "utf8", timeout: 10000 }); - out.write(`${theme.cyan("\u21A9 rolled back")} ${dirty.length} files restored to last commit.\n`); - out.write(theme.dim(" Git reflog untouched — all commits preserved.\n")); - } catch (err) { - out.write(`\u2717 ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ── /scaffold ───────────────────────────────── - -async function scaffoldSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const parts = arg.trim().split(/\s+/); - const type = parts[0]?.toLowerCase() ?? ""; - const name = parts.slice(1).join(" "); - - if (!type || !name) { - out.write(SCAFFOLD_USAGE + "\n"); - return; - } - - if (!isValidScaffoldType(type)) { - out.write(`invalid type: ${type} — use component, route, or module\n`); - return; - } - - try { - out.write(`scaffolding ${type} "${name}"...\n`); - const r = await generateScaffold(ctx.api, type as ScaffoldType, name); - for (const f of r.files) { - out.write(` created: ${theme.bold(f.path)} (${f.content.split("\n").length} lines)\n`); - } - out.write(`template: ${theme.dim(r.template_used)}\n`); - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ── /purge ───────────────────────────────────── - -async function purgeSlash(ctx: AppContext, out: Writable): Promise { - const registry = getRegistry(); - const { clearedPins, removedFiles } = registry.purge(); - - // Reset the registry completely - resetRegistry(); - - // Clear screen - out.write("\x1b[2J\x1b[H"); - - const lines: string[] = []; - if (clearedPins > 0) lines.push(`${clearedPins} pinned files`); - if (removedFiles > 0) lines.push(`${removedFiles} temp files`); - lines.push("UVT cap reset"); - lines.push("context flushed"); - - out.write(`${theme.cyan("🧹 purged")} ${lines.join(" · ")}\n`); - out.write(theme.dim(" Session goal preserved. Agent memory reset to lean baseline.\n")); -} - -// ── /port ───────────────────────────────────── - -async function portSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const parts = arg.trim().split(/\s+/); - const targetPath = parts[0]; - const targetLang = parts[1]?.toLowerCase(); - - if (!targetPath || !targetLang) { - out.write("usage: /port \n"); - out.write(" /port src/utils.ts rust\n"); - out.write(" /port src/services/ python\n"); - return; - } - - try { - const files = readSource(targetPath); - out.write(`reading ${files.length} file(s) from ${targetPath}...\n`); - - const r = await portCode(ctx.api, files, targetLang); - out.write(`translated → ${r.files.length} file(s)\n`); - - const outDir = `ported_${targetLang}`; - const written = writePortedFiles(r.files, outDir); - for (const w of written) { - out.write(` ${theme.bold(w)}\n`); - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ── /stage-diff ──────────────────────────────── - -async function stageDiffSlash(ctx: AppContext, out: Writable): Promise { - try { - const r = generateDiff(); - - if (r.files.length === 0) { - out.write("(working tree clean — nothing to stage)\n"); - return; - } - - out.write(theme.cyan("📋 Stage Diff\n")); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - - out.write(` ${r.stats.filesChanged} files +${r.stats.additions} -${r.stats.deletions}\n\n`); - - for (const f of r.files.slice(0, 15)) { - out.write(` ${theme.muted(f)}\n`); - } - if (r.files.length > 15) { - out.write(` ${theme.dim(`... and ${r.files.length - 15} more`)}\n`); - } - - out.write(`\n${theme.bold("Suggested commit:")}\n`); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - out.write(r.commitMessage + "\n"); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - - const diffLines = r.diff.split("\n").slice(0, 30); - out.write(`\n${theme.dim("Diff preview (first 30 lines):")}\n`); - for (const line of diffLines) { - if (line.startsWith("+")) out.write(theme.dim(line) + "\n"); - else if (line.startsWith("-")) out.write(theme.muted(line) + "\n"); - else out.write(theme.dim(line) + "\n"); - } - - if (r.diff.split("\n").length > 30) { - out.write(theme.dim(" ... (truncated)\n")); - } - - out.write(`\n${theme.dim(" Copy the commit message above and commit when ready.")}\n`); - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ── /revert ───────────────────────────────────── - -async function revertSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const target = arg.trim(); - if (!target) { - out.write("usage: /revert surgical rollback\n"); - out.write(" /revert src/core/old.ts revert single file\n"); - out.write(" /revert step-3 revert to checkpoint (coming soon)\n"); - return; - } - - const cwd = process.cwd(); - const gitDir = join(cwd, ".git"); - if (!existsSync(gitDir)) { - out.write(theme.muted("Not in a git repository.\n")); - return; - } - - const { execSync } = require("node:child_process") as typeof import("node:child_process"); - - if (target.startsWith("step-") || target.match(/^\d+$/)) { - out.write(theme.muted("Step-based revert not yet available. Use /rollback to revert all, or /revert for a single file.\n")); - out.write(theme.dim(" Tracked step checkpoints planned for future release.\n")); - return; - } - - try { - const isTracked = (() => { - try { - execSync(`git ls-files --error-unmatch "${target}"`, { cwd, encoding: "utf8", timeout: 3000 }); - return true; - } catch { return false; } - })(); - - if (!isTracked) { - out.write(`${theme.muted(target)} is not tracked by git.\n`); - return; - } - - const diffOut = execSync(`git diff --name-only -- "${target}"`, { cwd, encoding: "utf8", timeout: 3000 }); - if (!diffOut.trim()) { - out.write(`(no uncommitted changes in ${target})\n`); - return; - } - - const fileDiff = execSync(`git diff -- "${target}"`, { cwd, encoding: "utf8", timeout: 5000 }); - const changes = fileDiff.trim().split("\n").length; - - out.write(`${theme.cyan("↩ Reverting")} ${theme.bold(target)} (${changes} line changes)\n`); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - - for (const line of fileDiff.split("\n").slice(0, 10)) { - if (line.startsWith("+")) out.write(theme.dim(line) + "\n"); - else if (line.startsWith("-")) out.write(theme.muted(line) + "\n"); - else out.write(theme.dim(line) + "\n"); - } - - const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert ${target} to last commit? [y/N] `)); - if (!ok) { - out.write("cancelled.\n"); - return; - } - - execSync(`git checkout -- "${target}"`, { cwd, encoding: "utf8", timeout: 10000 }); - out.write(`${theme.cyan("↩ reverted")} ${target} restored to last commit.\n`); - } catch (err: any) { - if (err?.stderr?.includes("did not match any file")) { - out.write(`not found: ${target}\n`); - } else { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } - } -} - // /help is generated from SLASH_COMMANDS (slash_registry.ts) — a command added // to the registry is automatically listed here, Tab-completable, and // did-you-mean'd. test/slash_registry.test.ts pins registry ↔ switch sync. @@ -881,140 +352,6 @@ const SECTION_ICONS: Record = { "HUD": "🖥", }; -// ── /add + /hud handlers ────────────────────── - -async function addSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const input = arg.trim().toLowerCase(); - - if (!input || input === "list" || input === "ls") { - const reg = getRegistry(); - const active = new Set(reg.hudElements); - out.write(theme.iceBlue("Available HUD elements:\n")); - out.write(theme.dim("──────────────────────────────────────────────────────\n")); - const sorted = Object.values(HUD_ELEMENTS).sort((a, b) => a.priority - b.priority); - for (const def of sorted) { - const mark = active.has(def.id) ? theme.cyan("●") : "○"; - const aliases = def.aliases.length ? theme.dim(" aka: " + def.aliases.join(", ")) : ""; - out.write(` ${mark} ${theme.bold(def.label)}${aliases}\n`); - out.write(` ${theme.dim(def.description)}\n`); - } - out.write("\n" + theme.dim("Usage: /add /hud remove /hud clear\n")); - return; - } - - const resolved = resolveHudElement(input); - if (!resolved) { - out.write(`unknown HUD element: "${arg.trim()}". /add list to see available.\n`); - return; - } - - const reg = getRegistry(); - if (reg.hasHudElement(resolved)) { - out.write(theme.dim(`HUD already active: ${HUD_ELEMENTS[resolved].label}. /hud remove ${resolved} to remove.\n`)); - return; - } - - const added = reg.addHudElement(resolved); - if (!added) { - out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is already active.\n`)); - return; - } - - const def = HUD_ELEMENTS[resolved]; - out.write(theme.cyan("✓") + ` HUD: ${theme.bold(def.label)} added\n`); - out.write(theme.dim(` ${def.description}\n`)); - - if (process.stdout.isTTY) { - const cols = process.stdout.columns ?? 80; - const state = buildHudState(reg); - const preview = renderHud(reg.hudElements, state, cols); - if (preview) out.write("\n" + preview + "\n"); - } -} - -async function hudSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const parts = arg.trim().split(/\s+/); - const sub = (parts[0] ?? "").toLowerCase(); - const target = parts.slice(1).join(" ").trim(); - const reg = getRegistry(); - - switch (sub) { - case "remove": - case "rm": - case "del": - case "delete": { - if (!target) { - out.write("usage: /hud remove \n"); - if (reg.hudElements.length > 0) { - out.write(theme.dim("active: " + reg.hudElements.map((id) => HUD_ELEMENTS[id].label).join(", ") + "\n")); - } - return; - } - const resolved = resolveHudElement(target); - if (!resolved) { - out.write(`unknown HUD element: "${target}". /hud list to see active.\n`); - return; - } - if (!reg.hasHudElement(resolved)) { - out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is not active.\n`)); - return; - } - reg.removeHudElement(resolved); - out.write(theme.cyan("✓") + ` HUD: ${theme.bold(HUD_ELEMENTS[resolved].label)} removed\n`); - break; - } - case "clear": - case "reset": { - const count = reg.hudElements.length; - if (count === 0) { - out.write(theme.dim("HUD is already clear.\n")); - return; - } - reg.clearHud(); - out.write(theme.cyan("✓") + ` HUD cleared (${count} element${count !== 1 ? "s" : ""} removed)\n`); - break; - } - case "list": - case "ls": - case "show": - case "": { - if (reg.hudElements.length === 0) { - out.write(theme.dim("HUD is empty. /add to add one. /add list to see available.\n")); - return; - } - out.write(theme.iceBlue("Active HUD elements:\n")); - out.write(theme.dim("──────────────────────────────────────────────────────\n")); - for (const id of reg.hudElements) { - const def = HUD_ELEMENTS[id]; - out.write(` ${theme.cyan("●")} ${theme.bold(def.label)} ${theme.dim("(" + def.aliases.join(", ") + ")")}\n`); - } - if (process.stdout.isTTY) { - const cols = process.stdout.columns ?? 80; - const state = buildHudState(reg); - const preview = renderHud(reg.hudElements, state, cols); - if (preview) out.write("\n" + preview + "\n"); - } - break; - } - default: - out.write("usage: /hud remove | /hud list | /hud clear\n"); - break; - } -} - -function buildHudState(reg: import("../core/context_registry.js").ContextRegistry): HudRenderState { - const live = timerLive(reg.hudTimer); - return { - tokensUsed: reg.uvtSpent, - tokensCap: reg.uvtCap ?? 1_000_000_000, - sessionMs: live.userMs + live.agentMs, - timer: reg.hudTimer, - streamedTokens: 0, - uvtUsed: reg.uvtSpent, - uvtCap: reg.uvtCap ?? 0, - }; -} - const USAGE_COL = 30; function printHelp(out: Writable, arg = ""): void { const name = arg.trim().replace(/^\//, "").toLowerCase(); @@ -1095,7 +432,7 @@ async function showPicker( : ctx.flags.agent; out.write(`tier: ${cat.tier}\n`); items.forEach((m, i) => { - const mark = m.id === current ? "\u203A" : m.available ? " " : "\uD83D\uDD12"; + const mark = m.id === current ? "›" : m.available ? " " : "🔒"; const cap = m.monthly_uvt_cap != null ? ` cap ${m.monthly_uvt_cap}` : ""; out.write(`${mark} ${String(i + 1).padStart(2)}. ${m.id}\t${m.label}${cap}\n`); }); @@ -1114,7 +451,7 @@ async function showPicker( // Show warning + confirm (same as existing select() logic) out.write( theme.dim( - `\u26A0 Switching ${kind === "model" ? "model" : "orchestrator"} to ${picked.label} will ` + + `⚠ Switching ${kind === "model" ? "model" : "orchestrator"} to ${picked.label} will ` + `restart the session and clear context.\n`, ), ); @@ -1181,627 +518,3 @@ async function showAudit(ctx: AppContext, out: Writable, arg: string): Promise { - try { - const r = await getVaultSnapshot(ctx.api, 800); - out.write(`vault: ${r.note_count} notes\n`); - } catch { - out.write("vault: unreachable\n"); - } -} - -async function vaultContextSlash(ctx: AppContext, out: Writable): Promise { - try { - await getVaultSnapshot(ctx.api, 2000); - out.write("vault context loaded for next agent turn.\n"); - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function vaultSearchSlash(ctx: AppContext, out: Writable, query: string): Promise { - if (!query) { out.write("usage: /vault-search \n"); return; } - try { - const r = await searchNotes(ctx.api, query, { limit: 10 }); - if (r.results.length === 0) { out.write("no results.\n"); return; } - for (const n of r.results) { - out.write(` ${n.title || n.path} (${n.path})\n`); - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function vaultRecentSlash(ctx: AppContext, out: Writable, arg: string): Promise { - try { - const n = Math.min(parseInt(arg) || 10, 50); - const r = await searchNotes(ctx.api, "", { limit: n }); - if (r.results.length === 0) { out.write("(empty vault)\n"); return; } - for (const row of r.results) { - out.write(` ${row.title || row.path}\n`); - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function vaultProjectSlash(ctx: AppContext, out: Writable, name: string): Promise { - if (!name) { out.write("usage: /vault-project \n"); return; } - try { - const r = await searchNotes(ctx.api, "", { project: name, limit: 20 }); - if (r.results.length === 0) { out.write(`no notes for project: ${name}\n`); return; } - for (const n of r.results) { - out.write(` ${n.title || n.path} [${n.tags.join(", ")}]\n`); - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function vaultTagSlash(ctx: AppContext, out: Writable, tag: string): Promise { - if (!tag) { out.write("usage: /vault-tag \n"); return; } - try { - const r = await notesByTag(ctx.api, tag, 20); - if (r.results.length === 0) { out.write(`no notes with tag: ${tag}\n`); return; } - for (const n of r.results) { - out.write(` ${n.title || n.path} (${n.path})\n`); - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ── Workflow slash handlers ───────────────────── - -async function workflowSlash(ctx: AppContext, out: Writable): Promise { - try { - const workflows = await listWorkflows(ctx.api); - out.write(`workflow: ${workflows.length} in vault · ${WORKFLOW_TEMPLATES.length} templates available\n`); - if (workflows.length > 0) { - for (const w of workflows.slice(0, 5)) out.write(` ${w.name} (${Math.round(w.size/1024)} KB)\n`); - } - } catch { - out.write("workflow: unavailable\n"); - } -} - -async function workflowTemplatesSlash(ctx: AppContext, out: Writable): Promise { - for (let i = 0; i < WORKFLOW_TEMPLATES.length; i++) { - const t = WORKFLOW_TEMPLATES[i]!; - out.write(` ${String(i+1)}. ${t.icon} ${t.name} (${t.category}, ${t.difficulty})\n`); - } - out.write("load: /workflow-template \n"); -} - -async function workflowTemplateSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const n = parseInt(arg); - if (isNaN(n) || n < 1 || n > WORKFLOW_TEMPLATES.length) { - out.write(`invalid: ${arg} (1-${WORKFLOW_TEMPLATES.length})\n`); - return; - } - const t = WORKFLOW_TEMPLATES[n - 1]!; - out.write(`${t.icon} ${t.name}: ${t.subtitle}\n`); - out.write(` ${t.workflow.nodes?.length || 0} nodes · ${t.workflow.edges?.length || 0} edges\n`); - out.write(` save with: aether workflow save ${t.id}\n`); -} - -async function vaultTreeSlash(ctx: AppContext, out: Writable): Promise { - try { - const r = await getNotesTree(ctx.api); - if (r.tree.length === 0) { out.write("(empty vault)\n"); return; } - for (const e of r.tree) { - out.write(` ${e.folder || "/"} (${e.count} notes)\n`); - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ── Agents slash handler ──────────────────────── - -async function agentsSlash(ctx: AppContext, out: Writable): Promise { - try { - const resp = await ctx.api.getJson<{ agents?: Array<{ - name?: string; status?: string; working_time?: string; - uvt_streamed?: number; task?: string; - }> }>(AGENTS_PATH); - const agents = resp.agents ?? []; - if (agents.length === 0) { - out.write("(no active agents)\n"); - return; - } - // Columns: name, status, working time, UVT streamed, task - const nameW = Math.max(20, ...agents.map(a => (a.name ?? "?").length)); - const statusW = 12; - const timeW = 14; - const uvtW = 12; - - // Header - const header = " " + - "name".padEnd(nameW) + " " + - "status".padEnd(statusW) + " " + - "time".padEnd(timeW) + " " + - "UVT".padEnd(uvtW) + " " + - "task"; - out.write(theme.bold(header) + "\n"); - out.write(theme.dim(" " + "─".repeat(nameW + statusW + timeW + uvtW + 30)) + "\n"); - - for (const a of agents) { - const name = (a.name ?? "?").padEnd(nameW); - const status = (a.status ?? "?").padEnd(statusW); - const time = (a.working_time ?? "—").padEnd(timeW); - const uvt = a.uvt_streamed != null - ? String(a.uvt_streamed).padEnd(uvtW) - : "—".padEnd(uvtW); - const task = (a.task ?? "").slice(0, 50); - - const statusColor = a.status === "running" ? theme.cyan : - a.status === "complete" ? theme.dim : theme.muted; - - out.write(` ${theme.bold(name)}${statusColor(status)}${theme.dim(time)}${theme.dim(uvt)}${task}\n`); - } - } catch { - out.write("agents: unreachable (are you logged in? aether auth login)\n"); - } -} - -// ── Orchestrator slash handlers ───────────────── - -async function delegateSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!requireOrchestrator(ctx, out)) return; - const parts = arg.split(/\s+/); - const model = parts[0]; - const task = parts.slice(1).join(" "); - if (!model || !task) { - out.write("usage: /delegate \n"); - return; - } - try { - const r = await delegateWorker(ctx.api, ctx.flags.agent!, model, task); - out.write(`delegated → worker ${r.worker_id} (${r.status}) running ${model}\n`); - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function treeSlash(ctx: AppContext, out: Writable): Promise { - if (!requireOrchestrator(ctx, out)) return; - try { - const r = await getOrchTree(ctx.api, ctx.flags.agent!); - out.write(theme.bold(`orchestrator: ${r.orchestrator}`) + "\n"); - if (r.workers.length === 0) { - out.write(" (no active sub-agents)\n"); - return; - } - // Columns: id, model, step, tokens, UVT - const idW = Math.max(10, ...r.workers.map(w => w.id.length)); - const modelW = Math.max(10, ...r.workers.map(w => w.model.length)); - const stepW = Math.max(12, ...r.workers.map(w => w.step.length)); - const header = " " + - "id".padEnd(idW) + " " + - "model".padEnd(modelW) + " " + - "step".padEnd(stepW) + " " + - "tokens".padEnd(10) + " " + - "UVT"; - out.write(theme.dim(header) + "\n"); - out.write(theme.dim(" " + "─".repeat(idW + modelW + stepW + 30)) + "\n"); - for (const w of r.workers) { - out.write( - ` ${theme.bold(w.id.padEnd(idW))} ` + - `${w.model.padEnd(modelW)} ` + - `${w.step.padEnd(stepW)} ` + - `${String(w.tokens).padEnd(10)} ` + - `${w.uvt}\n` - ); - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function broadcastSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!requireOrchestrator(ctx, out)) return; - const message = arg.trim(); - if (!message) { - out.write("usage: /broadcast \"\"\n"); - return; - } - try { - const r = await broadcastToAgents(ctx.api, ctx.flags.agent!, message); - out.write(`broadcast → delivered to ${r.delivered_to} sub-agents\n`); - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ═════════════════════════════════════════════════════════════════════ -// Media pipeline — /photogen /frame /re-frame /videogen /sequence -// /animate /re-cut /output /storyboard handlers -// ═════════════════════════════════════════════════════════════════════ - -// NOTE: gatherSlash was originally defined above and is preserved. -// It was not removed — only its closing brace block was used as the anchor. - -async function gatherSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!requireOrchestrator(ctx, out)) return; - const workerId = arg.trim(); - if (!workerId) { out.write("usage: /gather \n"); return; } - try { - const r = await gatherResults(ctx.api, ctx.flags.agent!, workerId); - if (r.results.length === 0) { out.write("(no results to gather)\n"); return; } - for (const res of r.results) { - out.write(`${theme.bold(res.worker_id)}:\n`); - if (res.files.length) out.write(` files: ${res.files.join(", ")}\n`); - if (res.diffs.length) out.write(` diffs: ${res.diffs.length} diff(s)\n`); - if (res.patches.length) out.write(` patches: ${res.patches.length} patch(es)\n`); - } - } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } -} - -function parseSlashFlags(raw: string, _kind: MediaKind): { prompt: string; flags: GenFlags } { - const flags: GenFlags = {}; - const parts = raw.split(/\s+/); - const promptParts: string[] = []; - let inFlags = false; - for (let i = 0; i < parts.length; i++) { - const p = parts[i] ?? ""; - if (!inFlags && p.startsWith("--")) { inFlags = true; } - if (inFlags) { - const next = parts[i + 1] ?? ""; - if (p === "--model" && next) { flags.model = next; i++; } - else if (p === "--aspect" && next) { flags.aspect = next; i++; } - else if ((p === "--count" || p === "--batch") && next) { flags.count = parseInt(next, 10) || 1; i++; } - else if (p === "--4k") { flags.fourK = true; } - else if (p === "--vector") { flags.vector = true; } - else if ((p === "--duration" || p === "--10s") && next) { flags.duration = parseInt(next, 10) || 10; i++; } - else if (p === "--1080p") { flags.hd1080 = true; } - else if (p === "--audio") { flags.audio = true; } - else if (p === "--save-to-vault") { flags.saveToVault = true; } - else if (p === "--ref" && next) { flags.ref = next; i++; } - else if (p === "--open") { flags.open = true; } - } else { - promptParts.push(p); - } - } - return { prompt: promptParts.join(" "), flags }; -} - -const SF = theme.dim; - -async function photogenSlash(ctx: AppContext, out: Writable, arg: string, _framed: boolean): Promise { - const { prompt, flags } = parseSlashFlags(arg, "image"); - if (!prompt) { - out.write("usage: /photogen [--model nano] [--aspect 16_9] [--count 4] [--4k] [--vector]\n"); - out.write("usage: /frame --aspect \n"); - return; - } - const kind: MediaKind = "image"; - const modelKey = flags.model ? resolveModelKey(flags.model) : autoRouteModel(prompt, kind); - const fullPrompt = buildMediaPrompt(prompt, flags, kind); - const count = Math.min(10, Math.max(1, flags.count ?? 1)); - const outdir = ensureOutputDir(); - out.write(SF(`generating ${count} image(s) with ${modelKey}: "${prompt}"\n\n`)); - for (let i = 0; i < count; i++) { - try { - const vp = count > 1 ? `${fullPrompt} [variant ${i + 1} of ${count}]` : fullPrompt; - out.write(SF(`[${i + 1}/${count}] `)); - const resp = await dispatchGeneration(ctx.api, vp, modelKey, flags); - if (resp.media_url) { - out.write(SF("downloading...\n")); - const filepath = await downloadMediaFile(ctx.api, resp.media_url, outdir, modelKey, kind, resp.filename); - const result: GenResult = { - model: modelKey, prompt: vp, kind, filepath, filename: basename(filepath), - url: resp.media_url, timestamp: new Date().toISOString(), flags, - }; - const entry = recordOutput(result); - out.write(` ${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); - out.write(` ${SF(resp.media_url)}\n\n`); - _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaPrompt = vp; _lastMediaKind = kind; - } else { - out.write(SF(" no media URL returned\n")); - } - } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } - } -} - -async function reframeSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!_lastMediaUrl || _lastMediaKind !== "image") { out.write(SF(" no previous image to edit\n")); return; } - if (!arg) { out.write("usage: /re-frame \n"); return; } - const editPrompt = `Edit the previously generated image: ${arg}`; - const flags: GenFlags = { model: "edit", ref: _lastMediaUrl }; - out.write(SF(`editing with gpt-image-2: "${arg}"\n\n`)); - try { - const resp = await dispatchGeneration(ctx.api, editPrompt, "vision_gpt_image2", flags); - if (resp.media_url) { - const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_gpt_image2", "image", resp.filename); - const entry = recordOutput({ model: "vision_gpt_image2", prompt: editPrompt, kind: "image", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); - _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_gpt_image2"; _lastMediaPrompt = editPrompt; - } - } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } -} - -async function videogenSlash(ctx: AppContext, out: Writable, arg: string, cinematic: boolean): Promise { - const { prompt, flags } = parseSlashFlags(arg, "video"); - if (!prompt) { out.write("usage: /videogen [--model seedance] [--duration 10] [--1080p] [--audio]\n /sequence \n"); return; } - const kind: MediaKind = "video"; - const modelKey = flags.model ? resolveModelKey(flags.model) : cinematic ? "seedance" : autoRouteModel(prompt, kind); - if (!flags.duration) flags.duration = 5; - const fullPrompt = buildMediaPrompt(prompt, flags, kind); - out.write(SF(`generating video with ${modelKey}: "${prompt}"\n\n`)); - try { - const resp = await dispatchGeneration(ctx.api, fullPrompt, modelKey, flags); - if (resp.media_url) { - out.write(SF("downloading video...\n")); - const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, kind, resp.filename); - const entry = recordOutput({ model: modelKey, prompt: fullPrompt, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n ${SF(resp.media_url)}\n\n`); - _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaPrompt = fullPrompt; _lastMediaKind = kind; - } - } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } -} - -async function animateSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const args = arg.trim().split(/\s+/); - const ref = args[0]; - const extraPrompt = args.slice(1).join(" "); - if (!ref) { out.write("usage: /animate [motion description]\n"); return; } - let refUrl = ref; - if (/^#?\d+$/.test(ref)) { - const entry = findOutput(ref.replace("#", "")); - if (entry) refUrl = entry.url; - else { out.write(SF(` no output matching "${ref}"\n`)); return; } - } - const prompt = `Animate this image into a fluid video sequence${extraPrompt ? ": " + extraPrompt : ""}`; - out.write(SF(`animating from ${refUrl.slice(0, 50)}...\n\n`)); - try { - const resp = await dispatchGeneration(ctx.api, prompt, "vision_seedance", { model: "seedance", ref: refUrl, duration: 5 }); - if (resp.media_url) { - const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video", resp.filename); - const entry = recordOutput({ model: "vision_seedance", prompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: { model: "seedance", ref: refUrl, duration: 5 } }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); - _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_seedance"; _lastMediaPrompt = prompt; _lastMediaKind = "video"; - } - } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } -} - -async function recutSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!_lastMediaUrl || _lastMediaKind !== "video") { out.write(SF(" no previous video to edit\n")); return; } - if (!arg) { out.write("usage: /re-cut \n"); return; } - const modelKey = _lastMediaModel || "vision_seedance"; - const editPrompt = `Edit the previously generated video: ${arg}`; - out.write(SF(`editing video: "${arg}"\n\n`)); - try { - const resp = await dispatchGeneration(ctx.api, editPrompt, modelKey, { model: modelKey, ref: _lastMediaUrl }); - if (resp.media_url) { - const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, "video", resp.filename); - const entry = recordOutput({ model: modelKey, prompt: editPrompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); - _lastMediaUrl = resp.media_url; _lastMediaPrompt = editPrompt; - } - } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } -} - -async function outputSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const parts = arg.split(/\s+/); - const sub = parts[0]?.toLowerCase(); - const ref = parts.slice(1).join(" "); - if (sub === "open" || sub === "o") { - if (!ref) { out.write("usage: /output open \n"); return; } - const entry = findOutput(ref); - if (!entry) { out.write(SF(` no output matching "${ref}"\n`)); return; } - try { openOutput(entry); out.write(`${theme.iceBlue("→")} opened ${entry.filename}\n`); } - catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } - return; - } - if (sub === "clean" || sub === "clear") { out.write(SF(` cleared ${clearOutput()} entries\n`)); return; } - const entries = listOutput(10); - if (!entries.length) { out.write(SF(" (no generations yet — use /photogen or /videogen)\n")); return; } - out.write(`${theme.iceBlue("📦")} RECENT GENERATIONS\n\n`); - for (const e of entries) { - const icon = e.kind === "video" ? "🎬" : e.kind === "3d" ? "🧊" : "🖼"; - const sp = e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt; - out.write(` ${theme.iceBlue("#" + e.index)} ${icon} ${e.filename}\n ${SF(`${e.model} ${(e.size_bytes/1024/1024).toFixed(1)}MB ${sp}`)}\n`); - } - out.write(SF("\n /output open — open in default viewer\n\n")); -} - -async function storyboardSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const parts = arg.trim().split(/\s+/); - const sub = parts[0]?.toLowerCase(); - const rest = parts.slice(1).join(" "); - if (sub === "list" || sub === "ls") { - const boards = listStoryboards(); - if (!boards.length) { out.write(SF(" (no saved storyboards)\n")); return; } - out.write(`\n${theme.iceBlue("🎬")} STORYBOARDS\n\n`); - for (const b of boards) out.write(` ${b.id.padEnd(20)} ${b.title.slice(0, 40)} ${b.scenes} scenes [${b.status}]\n`); - return; - } - if (sub === "view" && rest) { - const sb = loadStoryboard(rest); - if (!sb) { out.write(SF(` no storyboard "${rest}"\n`)); return; } - sbPreview(sb, out); - return; - } - if (sub === "--preview" || sub === "--generate" || sub === "--animate" || sub === "--render") { - const boards = listStoryboards(); - const id = rest || (boards[0]?.id ?? ""); - const sb = loadStoryboard(id); - if (!sb) { out.write(SF(" no storyboard found\n")); return; } - const phase = sub.replace("--", ""); - if (phase === "preview") { sbPreview(sb, out); return; } - await sbRender(ctx, sb, phase, out); - return; - } - if (!arg.trim()) { out.write("usage: /storyboard [--scenes n] [--style style]\n /storyboard --preview|--generate|--animate|--render [id]\n /storyboard list|view \n"); return; } - let sourceType: "prompt" | "script_file" = "prompt"; - let source = arg; - let opts: { scenes?: number; style?: string } = {}; - const firstArg = parts[0] ?? ""; - if (fsExistsSync(firstArg) && /\.(md|txt)$/i.test(firstArg)) { - sourceType = "script_file"; source = firstArg; opts = sbFlagParse(parts.slice(1).join(" ")); - } else { - const fi = parts.findIndex(p => p.startsWith("--")); - if (fi > 0) { source = parts.slice(0, fi).join(" "); opts = sbFlagParse(parts.slice(fi).join(" ")); } - } - out.write(SF("parsing storyboard...\n")); - try { - const result = await parseStoryboard(ctx.api, source, sourceType, opts); - const sb = { id: `sb_${Date.now().toString(36)}`, title: result.title, source_type: sourceType, source, style: result.style, total_scenes: result.scenes.length, scenes: result.scenes, created_at: new Date().toISOString(), status: "draft" as const }; - saveStoryboard(sb); - sbPreview(sb, out); - out.write(SF("\n/storyboard --generate to generate all keyframes\n")); - out.write(SF("/storyboard --animate to animate the sequence\n")); - out.write(SF("/storyboard --render full pipeline\n")); - } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } -} - -function sbFlagParse(raw: string): { scenes?: number; style?: string } { - const r: { scenes?: number; style?: string } = {}; - const p = raw.split(/\s+/); - for (let i = 0; i < p.length; i++) { - const next = p[i + 1] ?? ""; - if ((p[i] === "--scenes" || p[i] === "-n") && next) { r.scenes = parseInt(next, 10); i++; } - else if (p[i] === "--style" && next) { r.style = next; i++; } - } - return r; -} - -function sbPreview(sb: any, out: Writable): void { - out.write(`\n${theme.iceBlue("🎬")} STORYBOARD: ${sb.title}\n`); - out.write(SF(` style: ${sb.style} | scenes: ${sb.total_scenes} | status: ${sb.status}\n\n`)); - for (const s of sb.scenes) { - out.write( - `${theme.iceBlue("SCENE " + s.index)} ${(s.shot_type||"WIDE").padEnd(8)} | ${(s.camera_movement||"static").padEnd(10)} | ${s.duration_sec}s | ${s.transition}\n` + - ` ${SF("visual:")} ${(s.keyframe_prompt||"").slice(0,80)}${(s.keyframe_prompt||"").length>80?"...":""}\n` + - ` ${SF("motion:")} ${(s.animation_prompt||"").slice(0,80)}\n` + - ` ${SF("palette:")} ${s.color_palette||"natural"} ${SF("light:")} ${s.lighting||"ambient"}\n\n` - ); - } -} - -async function sbRender(ctx: AppContext, sb: any, phase: string, out: Writable): Promise { - if (phase === "generate" || phase === "render") { - out.write(SF(`generating ${sb.total_scenes} keyframes...\n\n`)); - for (const s of sb.scenes) { - out.write(SF(` scene ${s.index}/${sb.total_scenes}: ${s.shot_type}\n`)); - try { - const mk = autoRouteModel(s.keyframe_prompt, "image"); - const resp = await dispatchGeneration(ctx.api, s.keyframe_prompt, mk, { aspect: "16_9" }); - if (resp.media_url) { - const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), mk, "image"); - s.generated_frame_url = resp.media_url; s.generated_frame_path = fp; - recordOutput({ model: mk, prompt: s.keyframe_prompt, kind: "image", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); - out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`); - } - } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); } - } - sb.status = "keyframes_generated"; saveStoryboard(sb); - } - if (phase === "animate" || phase === "render") { - const anim = sb.scenes.filter((s: any) => s.generated_frame_url); - out.write(SF(`animating ${anim.length} scenes...\n\n`)); - for (const s of anim) { - out.write(SF(` scene ${s.index}: ${s.camera_movement} over ${s.duration_sec}s\n`)); - try { - const resp = await dispatchGeneration(ctx.api, s.animation_prompt, "vision_seedance", { model: "seedance", ref: s.generated_frame_url, duration: s.duration_sec }); - if (resp.media_url) { - const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video"); - recordOutput({ model: "vision_seedance", prompt: s.animation_prompt, kind: "video", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); - out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`); - } - } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); } - } - sb.status = "animated"; saveStoryboard(sb); - } -} - -// ── /test-drive ───────────────────────────────── - -async function testDriveSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!requireOrchestrator(ctx, out)) return; - - const target = arg.trim().replace(/^["']|["']$/g, ""); - if (!target) { - out.write('usage: /test-drive ""\n'); - out.write(' /test-drive "POST /api/users"\n'); - out.write(' /test-drive "src/utils/validate.ts:validateEmail"\n'); - return; - } - - try { - out.write(`${theme.cyan("🧪 test-drive")} targeting: ${theme.bold(target)}\n`); - out.write(theme.dim(" Generating test matrix, running, iterating until green...\n\n")); - - const r = await startTestDrive(ctx.api, ctx.flags.agent!, target, process.cwd()); - - if (r.status === "passed") { - out.write(`${theme.cyan("✓ all tests pass")} after ${r.iterations} iteration(s)\n`); - if (r.final_result) { - out.write(` ${r.final_result.passed} passed · ${r.final_result.failed} failed\n`); - } - if (r.patches.length > 0) { - out.write(` ${r.patches.length} source file(s) modified\n`); - for (const p of r.patches) { - out.write(` ${theme.bold(p.file)}\n`); - } - } - } else { - out.write(`${theme.muted("✗ tests did not converge")} after ${r.iterations} iteration(s)\n`); - if (r.final_result?.errors.length) { - for (const e of r.final_result.errors.slice(0, 3)) { - out.write(` ${theme.muted(e.slice(0, 200))}\n`); - } - } - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ── /bench ───────────────────────────────────── - -async function benchSlash(ctx: AppContext, out: Writable, arg: string): Promise { - if (!requireOrchestrator(ctx, out)) return; - - const target = arg.trim(); - if (!target) { - out.write("usage: /bench \n"); - out.write(" /bench src/services/search.ts:fullTextSearch\n"); - out.write(" /bench GET /api/search\n"); - return; - } - - try { - out.write(`${theme.cyan("⚡ benchmarking")} ${theme.bold(target)}...\n`); - - const r = await runBenchmark(ctx.api, ctx.flags.agent!, target); - - if (r.complexity) { - out.write(`\n complexity: ${theme.bold(r.complexity)}\n`); - } - if (r.bottlenecks.length > 0) { - out.write(`\n ${theme.muted("bottlenecks:")}\n`); - for (const b of r.bottlenecks) { - out.write(` ${theme.muted("•")} ${b}\n`); - } - } - if (r.optimizations.length > 0) { - out.write(`\n ${theme.cyan("optimizations:")}\n`); - for (const o of r.optimizations) { - out.write(` ${theme.cyan("→")} ${o.description} ${theme.dim(`(${o.improvement})`)}\n`); - } - } - if (r.patches.length > 0) { - out.write(`\n ${r.patches.length} optimization(s) applied\n`); - for (const p of r.patches) { - out.write(` ${theme.bold(p.file)}\n`); - } - } - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } -} diff --git a/src/commands/slash_codegen.ts b/src/commands/slash_codegen.ts new file mode 100644 index 0000000..dc70517 --- /dev/null +++ b/src/commands/slash_codegen.ts @@ -0,0 +1,159 @@ +// In-REPL codegen/verification slash commands: /scaffold /port /test-drive +// /bench. Split out of slash.ts (was 1807 lines) to keep each command group +// under the repo's ~800-line file convention. + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { theme } from "../ui/theme.js"; +import { generateScaffold, isValidScaffoldType, SCAFFOLD_USAGE, type ScaffoldType } from "../core/scaffold.js"; +import { portCode, readSource, writePortedFiles } from "../core/port.js"; +import { startTestDrive } from "../core/test_drive.js"; +import { runBenchmark } from "../core/bench.js"; +import { requireOrchestrator } from "../core/orchestrator.js"; + +// ── /scaffold ───────────────────────────────── + +export async function scaffoldSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const parts = arg.trim().split(/\s+/); + const type = parts[0]?.toLowerCase() ?? ""; + const name = parts.slice(1).join(" "); + + if (!type || !name) { + out.write(SCAFFOLD_USAGE + "\n"); + return; + } + + if (!isValidScaffoldType(type)) { + out.write(`invalid type: ${type} — use component, route, or module\n`); + return; + } + + try { + out.write(`scaffolding ${type} "${name}"...\n`); + const r = await generateScaffold(ctx.api, type as ScaffoldType, name); + for (const f of r.files) { + out.write(` created: ${theme.bold(f.path)} (${f.content.split("\n").length} lines)\n`); + } + out.write(`template: ${theme.dim(r.template_used)}\n`); + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +// ── /port ───────────────────────────────────── + +export async function portSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const parts = arg.trim().split(/\s+/); + const targetPath = parts[0]; + const targetLang = parts[1]?.toLowerCase(); + + if (!targetPath || !targetLang) { + out.write("usage: /port \n"); + out.write(" /port src/utils.ts rust\n"); + out.write(" /port src/services/ python\n"); + return; + } + + try { + const files = readSource(targetPath); + out.write(`reading ${files.length} file(s) from ${targetPath}...\n`); + + const r = await portCode(ctx.api, files, targetLang); + out.write(`translated → ${r.files.length} file(s)\n`); + + const outDir = `ported_${targetLang}`; + const written = writePortedFiles(r.files, outDir); + for (const w of written) { + out.write(` ${theme.bold(w)}\n`); + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +// ── /test-drive ───────────────────────────────── + +export async function testDriveSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!requireOrchestrator(ctx, out)) return; + + const target = arg.trim().replace(/^["']|["']$/g, ""); + if (!target) { + out.write('usage: /test-drive ""\n'); + out.write(' /test-drive "POST /api/users"\n'); + out.write(' /test-drive "src/utils/validate.ts:validateEmail"\n'); + return; + } + + try { + out.write(`${theme.cyan("🧪 test-drive")} targeting: ${theme.bold(target)}\n`); + out.write(theme.dim(" Generating test matrix, running, iterating until green...\n\n")); + + const r = await startTestDrive(ctx.api, ctx.flags.agent!, target, process.cwd()); + + if (r.status === "passed") { + out.write(`${theme.cyan("✓ all tests pass")} after ${r.iterations} iteration(s)\n`); + if (r.final_result) { + out.write(` ${r.final_result.passed} passed · ${r.final_result.failed} failed\n`); + } + if (r.patches.length > 0) { + out.write(` ${r.patches.length} source file(s) modified\n`); + for (const p of r.patches) { + out.write(` ${theme.bold(p.file)}\n`); + } + } + } else { + out.write(`${theme.muted("✗ tests did not converge")} after ${r.iterations} iteration(s)\n`); + if (r.final_result?.errors.length) { + for (const e of r.final_result.errors.slice(0, 3)) { + out.write(` ${theme.muted(e.slice(0, 200))}\n`); + } + } + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +// ── /bench ───────────────────────────────────── + +export async function benchSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!requireOrchestrator(ctx, out)) return; + + const target = arg.trim(); + if (!target) { + out.write("usage: /bench \n"); + out.write(" /bench src/services/search.ts:fullTextSearch\n"); + out.write(" /bench GET /api/search\n"); + return; + } + + try { + out.write(`${theme.cyan("⚡ benchmarking")} ${theme.bold(target)}...\n`); + + const r = await runBenchmark(ctx.api, ctx.flags.agent!, target); + + if (r.complexity) { + out.write(`\n complexity: ${theme.bold(r.complexity)}\n`); + } + if (r.bottlenecks.length > 0) { + out.write(`\n ${theme.muted("bottlenecks:")}\n`); + for (const b of r.bottlenecks) { + out.write(` ${theme.muted("•")} ${b}\n`); + } + } + if (r.optimizations.length > 0) { + out.write(`\n ${theme.cyan("optimizations:")}\n`); + for (const o of r.optimizations) { + out.write(` ${theme.cyan("→")} ${o.description} ${theme.dim(`(${o.improvement})`)}\n`); + } + } + if (r.patches.length > 0) { + out.write(`\n ${r.patches.length} optimization(s) applied\n`); + for (const p of r.patches) { + out.write(` ${theme.bold(p.file)}\n`); + } + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} diff --git a/src/commands/slash_context.ts b/src/commands/slash_context.ts new file mode 100644 index 0000000..888f90e --- /dev/null +++ b/src/commands/slash_context.ts @@ -0,0 +1,300 @@ +// In-REPL context-management slash commands: /pin /drop /snapshot /limit +// /token-budget /audit-receipt /purge. Split out of slash.ts (was 1807 lines) +// to keep each command group under the repo's ~800-line file convention. + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { join } from "node:path"; +import { theme } from "../ui/theme.js"; +import { box } from "../ui/box.js"; +import { + getRegistry, resetRegistry, saveSnapshot, loadSnapshot, listSnapshots, + ContextRegistry, syncToBackend, loadFromBackend, +} from "../core/context_registry.js"; +import { readCustodyLog } from "../core/custody.js"; +import { fetchTrail } from "../core/audit.js"; +import type { AuditEntry } from "../core/audit.js"; + +// ── /pin ────────────────────────────────────── + +export async function pinSlash(ctx: AppContext, out: Writable, arg: string, _line: string): Promise { + if (!arg.trim() || arg.trim() === "list" || arg.trim() === "ls") { + const pins = getRegistry().pins; + if (pins.length === 0) { + out.write("(no pinned files — use /pin [reason] to pin one)\n"); + return; + } + out.write(theme.cyan("📌 Pinned Context\n")); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + for (const p of pins) { + out.write(` ${theme.bold(p.label)} ${theme.dim(p.path)} ${theme.muted(p.reason)}\n`); + } + return; + } + + const parts = arg.trim().split(/\s+/); + const pth = parts[0]!; + const reason = parts.slice(1).join(" ") || "pinned"; + + const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth); + const label = pth.split("/").pop() || pth; + + const entry = getRegistry().pin(resolved, label, reason); + out.write(`${theme.cyan("📌 pinned")} ${theme.bold(entry.label)} ${theme.dim(entry.path)} (${entry.reason})\n`); + out.write(theme.dim(" This file will persist in context across /recon and /autonomous-execution loops.\n")); + syncAfter(ctx); +} + +// ── /drop ───────────────────────────────────── + +export async function dropSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!arg.trim()) { + const registry = getRegistry(); + out.write("usage: /drop evict a file from memory context\n"); + out.write(" /drop src/core/old.ts\n"); + if (registry.pins.length > 0) { + out.write("\n Pinned files (use /pin list for details):\n"); + for (const p of registry.pins) { + out.write(` ${theme.dim(p.label)}\n`); + } + } + if (registry.drops.length > 0) { + out.write("\n Recently dropped:\n"); + for (const d of registry.drops.slice(-5)) { + out.write(` ${theme.dim(d)}\n`); + } + } + return; + } + + const pth = arg.trim(); + const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth); + const wasPinned = getRegistry().isPinned(resolved); + getRegistry().drop(resolved); + + if (wasPinned) { + out.write(`${theme.cyan("🗑 dropped")} ${theme.bold(pth)} — removed from pinned context\n`); + } else { + out.write(`${theme.cyan("🗑 evicted")} ${theme.dim(pth)}\n`); + out.write(theme.dim(" (wasn't pinned, but will be excluded from future context loads)\n")); + } + syncAfter(ctx); +} + +// ── /snapshot ───────────────────────────────── + +export async function snapshotSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const registry = getRegistry(); + const sub = arg.trim().split(/\s+/)[0]?.toLowerCase() ?? ""; + + if (sub === "resume" || sub === "load") { + const id = arg.trim().split(/\s+/).slice(1).join(" "); + if (!id) { + // Try cloud backend first + const cloudLoaded = await loadFromBackend(ctx.api); + if (cloudLoaded) { + const reg = getRegistry(); + out.write(`${theme.cyan("☁ loaded from cloud")} ${theme.bold(reg.sessionLabel)} (${reg.pins.length} pins, cap ${reg.uvtCap ?? "none"})\n`); + out.write(theme.dim(" Use /pin list to see restored context.\n")); + return; + } + const snaps = listSnapshots(); + if (snaps.length === 0) { + out.write("(no snapshots — use /snapshot to save one)\n"); + return; + } + out.write(theme.cyan("💾 Saved Snapshots\n")); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + for (const s of snaps) { + const pinCnt = s.data.pins.length; + const cap = s.data.uvtCap ? ` cap ${s.data.uvtCap} UVT` : ""; + out.write(` ${theme.bold(s.id)} ${theme.dim(s.data.createdAt)} ${s.data.sessionLabel} (${pinCnt} pins${cap})\n`); + } + out.write(theme.dim("\n /snapshot resume to restore a snapshot\n")); + return; + } + const data = loadSnapshot(id); + if (!data) { + out.write(`no snapshot: ${id} (use /snapshot to list)\n`); + return; + } + const restored = ContextRegistry.fromSnapshot(data); + resetRegistry(); + Object.assign(getRegistry(), restored); + out.write(`${theme.cyan("📂 restored")} ${theme.bold(data.sessionLabel)} (${data.pins.length} pins, cap ${data.uvtCap ?? "none"})\n`); + out.write(` cwd: ${data.cwd}\n`); + out.write(theme.dim(" Use /pin list to see restored context.\n")); + return; + } + + if (sub === "list" || sub === "ls") { + const snaps = listSnapshots(); + if (snaps.length === 0) { + out.write("(no snapshots)\n"); + return; + } + out.write(theme.cyan("💾 Snapshots\n")); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + for (const s of snaps) { + const pinCnt = s.data.pins.length; + out.write(` ${theme.bold(s.data.sessionLabel)} ${theme.dim(s.data.createdAt)} (${pinCnt} pins)\n`); + } + return; + } + + const snapPath = saveSnapshot(registry); + const basename = snapPath.split("/").pop() || snapPath; + out.write(`${theme.cyan("💾 snapshot saved")} ${theme.bold(basename)}\n`); + out.write(` ${theme.dim(snapPath)}\n`); + out.write(` pins: ${registry.pins.length} UVT cap: ${registry.uvtCap ?? "none"} drops: ${registry.drops.length}\n`); + out.write(theme.dim(" Resume with: /snapshot resume \n")); + syncAfter(ctx); +} + +// ── /limit ──────────────────────────────────── + +/** Fire-and-forget backend sync. Never blocks the REPL. */ +function syncAfter(ctx: AppContext): void { + void syncToBackend(ctx.api).catch(() => {}); +} + +function renderUvtBar(pct: number, width: number): string { + const filled = Math.round((pct / 100) * width); + const empty = width - filled; + const color = pct > 80 ? theme.muted : pct > 50 ? theme.dim : theme.cyan; + return color("[" + "█".repeat(filled) + "░".repeat(empty) + "]"); +} + +export async function limitSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const registry = getRegistry(); + + if (!arg.trim()) { + const current = registry.uvtCap; + const spent = registry.uvtSpent; + if (current == null) { + out.write("UVT cap: none (uncapped)\n"); + } else { + const remaining = Math.max(0, current - spent); + const pct = current > 0 ? Math.round((spent / current) * 100) : 0; + const bar = renderUvtBar(pct, 20); + out.write(`UVT cap: ${theme.bold(String(current))} spent: ${spent} remaining: ${remaining} ${bar}\n`); + } + out.write(theme.dim(" /limit set cap (e.g., /limit 50000)\n")); + out.write(theme.dim(" /limit off remove cap\n")); + return; + } + + if (arg.trim().toLowerCase() === "off" || arg.trim().toLowerCase() === "none") { + registry.uvtCap = null; + out.write(theme.cyan("UVT cap removed — session is uncapped.\n")); + return; + } + + const n = Number(arg.trim()); + if (!Number.isFinite(n) || n <= 0) { + out.write(`invalid: ${arg} — use a positive number (e.g., /limit 50000)\n`); + return; + } + + registry.setUvtCap(Math.floor(n)); + out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))} — agent will pause and ask permission if ceiling hit\n`); + syncAfter(ctx); +} + +// ── /audit-receipt ──────────────────────────── + +function hashShortCustody(v: unknown): string | null { + if (v == null) return null; + if (typeof v === "string") return v.slice(0, 12); + if (typeof v === "object") { + const o = v as Record; + const inner = o["hash"] ?? o["env_hash"] ?? o["commitment_hash"] ?? o["digest"]; + if (inner != null) return String(inner).slice(0, 12); + } + return "✓"; +} + +export async function auditReceiptSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const nArg = Number(arg); + const limit = Number.isInteger(nArg) && nArg > 0 ? Math.min(nArg, 100) : 20; + + const custody = readCustodyLog(limit); + let serverEntries: AuditEntry[] = []; + try { + serverEntries = await fetchTrail(ctx.api, { limit }); + } catch { + /* offline — local custody is enough */ + } + + out.write(theme.cyan("🧾 Audit Receipt\n")); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + + const H_TIME = 26; + const H_ORDER = 14; + const H_EVENT = 14; + const H_COMMIT = 14; + const H_PATH = 20; + + const header = + "time".padEnd(H_TIME) + + "order_id".padEnd(H_ORDER) + + "event".padEnd(H_EVENT) + + "commitment".padEnd(H_COMMIT) + + "path"; + out.write(theme.bold(" " + header) + "\n"); + out.write(theme.dim(" " + "─".repeat(H_TIME + H_ORDER + H_EVENT + H_COMMIT + H_PATH)) + "\n"); + + for (const c of custody) { + const ts = c.received_at != null ? new Date(c.received_at).toISOString().padEnd(H_TIME) : "—".padEnd(H_TIME); + const oid = (String(c.order_id ?? "—")).slice(0, H_ORDER - 1).padEnd(H_ORDER); + const evt = "chat_turn".padEnd(H_EVENT); + const comm = (hashShortCustody(c.commitment) ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT); + const pathCol = String(c.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH); + out.write(` ${theme.dim(ts)}${oid}${theme.cyan(evt)}${theme.dim(comm)}${pathCol}\n`); + } + + for (const e of serverEntries) { + const ts = String(e.timestamp).padEnd(H_TIME); + const oid = e.orderId.slice(0, H_ORDER - 1).padEnd(H_ORDER); + const evt = e.eventType.padEnd(H_EVENT); + const comm = (e.commitmentHash ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT); + const pathCol = (e.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH); + out.write(` ${theme.dim(ts)}${theme.dim(oid)}${theme.muted(evt)}${theme.dim(comm)}${pathCol}\n`); + } + + const totalEntries = custody.length + serverEntries.length; + const uvtTotal = getRegistry().uvtSpent; + const boxContent = [ + "", + ` Total entries: ${totalEntries}`, + ` UVT spent: ${uvtTotal}`, + ` UVT cap: ${getRegistry().uvtCap ?? "none"}`, + "", + " Export proof: aether receipt ", + " Full log: /logs-view", + "", + ]; + out.write("\n" + box(boxContent, { width: 60 }) + "\n"); +} + +// ── /purge ───────────────────────────────────── + +export async function purgeSlash(ctx: AppContext, out: Writable): Promise { + const registry = getRegistry(); + const { clearedPins, removedFiles } = registry.purge(); + + // Reset the registry completely + resetRegistry(); + + // Clear screen + out.write("\x1b[2J\x1b[H"); + + const lines: string[] = []; + if (clearedPins > 0) lines.push(`${clearedPins} pinned files`); + if (removedFiles > 0) lines.push(`${removedFiles} temp files`); + lines.push("UVT cap reset"); + lines.push("context flushed"); + + out.write(`${theme.cyan("🧹 purged")} ${lines.join(" · ")}\n`); + out.write(theme.dim(" Session goal preserved. Agent memory reset to lean baseline.\n")); +} diff --git a/src/commands/slash_git_tools.ts b/src/commands/slash_git_tools.ts new file mode 100644 index 0000000..9f15b24 --- /dev/null +++ b/src/commands/slash_git_tools.ts @@ -0,0 +1,179 @@ +// In-REPL git-backed rollback slash commands: /rollback /revert /stage-diff. +// Split out of slash.ts (was 1807 lines) to keep each command group under +// the repo's ~800-line file convention. + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { theme } from "../ui/theme.js"; +import { generateDiff } from "../core/stage_diff.js"; + +// ── /rollback ───────────────────────────────── + +export async function rollbackSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const n = parseInt(arg.trim()) || 1; + if (n < 1 || n > 50) { + out.write("usage: /rollback [n] revert last n filesystem changes (1-50, default 1)\n"); + return; + } + + const cwd = process.cwd(); + const gitDir = join(cwd, ".git"); + if (!existsSync(gitDir)) { + out.write(theme.muted("Not in a git repository. /rollback requires git for safe undo.\n")); + return; + } + + const { execSync } = require("node:child_process") as typeof import("node:child_process"); + try { + const status = execSync("git diff --name-only", { cwd, encoding: "utf8", timeout: 5000 }); + const dirty = status.trim().split("\n").filter(Boolean); + if (dirty.length === 0) { + out.write("(working tree clean — nothing to rollback)\n"); + return; + } + + out.write(`${theme.cyan("↩ Ready to rollback")} ${dirty.length} files changed\n`); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + const show = dirty.slice(0, 20); + for (const f of show) { + out.write(` ${theme.muted(f)}\n`); + } + if (dirty.length > 20) { + out.write(` ${theme.dim(`... and ${dirty.length - 20} more`)}\n`); + } + + const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert all ${dirty.length} uncommitted changes? [y/N] `)); + if (!ok) { + out.write("cancelled.\n"); + return; + } + + execSync("git checkout -- .", { cwd, encoding: "utf8", timeout: 10000 }); + out.write(`${theme.cyan("↩ rolled back")} ${dirty.length} files restored to last commit.\n`); + out.write(theme.dim(" Git reflog untouched — all commits preserved.\n")); + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +// ── /stage-diff ──────────────────────────────── + +export async function stageDiffSlash(ctx: AppContext, out: Writable): Promise { + try { + const r = generateDiff(); + + if (r.files.length === 0) { + out.write("(working tree clean — nothing to stage)\n"); + return; + } + + out.write(theme.cyan("📋 Stage Diff\n")); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + + out.write(` ${r.stats.filesChanged} files +${r.stats.additions} -${r.stats.deletions}\n\n`); + + for (const f of r.files.slice(0, 15)) { + out.write(` ${theme.muted(f)}\n`); + } + if (r.files.length > 15) { + out.write(` ${theme.dim(`... and ${r.files.length - 15} more`)}\n`); + } + + out.write(`\n${theme.bold("Suggested commit:")}\n`); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + out.write(r.commitMessage + "\n"); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + + const diffLines = r.diff.split("\n").slice(0, 30); + out.write(`\n${theme.dim("Diff preview (first 30 lines):")}\n`); + for (const line of diffLines) { + if (line.startsWith("+")) out.write(theme.dim(line) + "\n"); + else if (line.startsWith("-")) out.write(theme.muted(line) + "\n"); + else out.write(theme.dim(line) + "\n"); + } + + if (r.diff.split("\n").length > 30) { + out.write(theme.dim(" ... (truncated)\n")); + } + + out.write(`\n${theme.dim(" Copy the commit message above and commit when ready.")}\n`); + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +// ── /revert ───────────────────────────────────── + +export async function revertSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const target = arg.trim(); + if (!target) { + out.write("usage: /revert surgical rollback\n"); + out.write(" /revert src/core/old.ts revert single file\n"); + out.write(" /revert step-3 revert to checkpoint (coming soon)\n"); + return; + } + + const cwd = process.cwd(); + const gitDir = join(cwd, ".git"); + if (!existsSync(gitDir)) { + out.write(theme.muted("Not in a git repository.\n")); + return; + } + + const { execSync } = require("node:child_process") as typeof import("node:child_process"); + + if (target.startsWith("step-") || target.match(/^\d+$/)) { + out.write(theme.muted("Step-based revert not yet available. Use /rollback to revert all, or /revert for a single file.\n")); + out.write(theme.dim(" Tracked step checkpoints planned for future release.\n")); + return; + } + + try { + const isTracked = (() => { + try { + execSync(`git ls-files --error-unmatch "${target}"`, { cwd, encoding: "utf8", timeout: 3000 }); + return true; + } catch { return false; } + })(); + + if (!isTracked) { + out.write(`${theme.muted(target)} is not tracked by git.\n`); + return; + } + + const diffOut = execSync(`git diff --name-only -- "${target}"`, { cwd, encoding: "utf8", timeout: 3000 }); + if (!diffOut.trim()) { + out.write(`(no uncommitted changes in ${target})\n`); + return; + } + + const fileDiff = execSync(`git diff -- "${target}"`, { cwd, encoding: "utf8", timeout: 5000 }); + const changes = fileDiff.trim().split("\n").length; + + out.write(`${theme.cyan("↩ Reverting")} ${theme.bold(target)} (${changes} line changes)\n`); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + + for (const line of fileDiff.split("\n").slice(0, 10)) { + if (line.startsWith("+")) out.write(theme.dim(line) + "\n"); + else if (line.startsWith("-")) out.write(theme.muted(line) + "\n"); + else out.write(theme.dim(line) + "\n"); + } + + const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert ${target} to last commit? [y/N] `)); + if (!ok) { + out.write("cancelled.\n"); + return; + } + + execSync(`git checkout -- "${target}"`, { cwd, encoding: "utf8", timeout: 10000 }); + out.write(`${theme.cyan("↩ reverted")} ${target} restored to last commit.\n`); + } catch (err: any) { + if (err?.stderr?.includes("did not match any file")) { + out.write(`not found: ${target}\n`); + } else { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } + } +} diff --git a/src/commands/slash_hud.ts b/src/commands/slash_hud.ts new file mode 100644 index 0000000..8b6c81a --- /dev/null +++ b/src/commands/slash_hud.ts @@ -0,0 +1,145 @@ +// In-REPL HUD slash commands: /add /hud. Split out of slash.ts (was 1807 +// lines) to keep each command group under the repo's ~800-line file +// convention. + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { theme } from "../ui/theme.js"; +import { getRegistry, type ContextRegistry } from "../core/context_registry.js"; +import { + HUD_ELEMENTS, resolveHudElement, + renderHud, timerLive, + type HudRenderState, +} from "../core/hud.js"; + +export async function addSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const input = arg.trim().toLowerCase(); + + if (!input || input === "list" || input === "ls") { + const reg = getRegistry(); + const active = new Set(reg.hudElements); + out.write(theme.iceBlue("Available HUD elements:\n")); + out.write(theme.dim("──────────────────────────────────────────────────────\n")); + const sorted = Object.values(HUD_ELEMENTS).sort((a, b) => a.priority - b.priority); + for (const def of sorted) { + const mark = active.has(def.id) ? theme.cyan("●") : "○"; + const aliases = def.aliases.length ? theme.dim(" aka: " + def.aliases.join(", ")) : ""; + out.write(` ${mark} ${theme.bold(def.label)}${aliases}\n`); + out.write(` ${theme.dim(def.description)}\n`); + } + out.write("\n" + theme.dim("Usage: /add /hud remove /hud clear\n")); + return; + } + + const resolved = resolveHudElement(input); + if (!resolved) { + out.write(`unknown HUD element: "${arg.trim()}". /add list to see available.\n`); + return; + } + + const reg = getRegistry(); + if (reg.hasHudElement(resolved)) { + out.write(theme.dim(`HUD already active: ${HUD_ELEMENTS[resolved].label}. /hud remove ${resolved} to remove.\n`)); + return; + } + + const added = reg.addHudElement(resolved); + if (!added) { + out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is already active.\n`)); + return; + } + + const def = HUD_ELEMENTS[resolved]; + out.write(theme.cyan("✓") + ` HUD: ${theme.bold(def.label)} added\n`); + out.write(theme.dim(` ${def.description}\n`)); + + if (process.stdout.isTTY) { + const cols = process.stdout.columns ?? 80; + const state = buildHudState(reg); + const preview = renderHud(reg.hudElements, state, cols); + if (preview) out.write("\n" + preview + "\n"); + } +} + +export async function hudSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const parts = arg.trim().split(/\s+/); + const sub = (parts[0] ?? "").toLowerCase(); + const target = parts.slice(1).join(" ").trim(); + const reg = getRegistry(); + + switch (sub) { + case "remove": + case "rm": + case "del": + case "delete": { + if (!target) { + out.write("usage: /hud remove \n"); + if (reg.hudElements.length > 0) { + out.write(theme.dim("active: " + reg.hudElements.map((id) => HUD_ELEMENTS[id].label).join(", ") + "\n")); + } + return; + } + const resolved = resolveHudElement(target); + if (!resolved) { + out.write(`unknown HUD element: "${target}". /hud list to see active.\n`); + return; + } + if (!reg.hasHudElement(resolved)) { + out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is not active.\n`)); + return; + } + reg.removeHudElement(resolved); + out.write(theme.cyan("✓") + ` HUD: ${theme.bold(HUD_ELEMENTS[resolved].label)} removed\n`); + break; + } + case "clear": + case "reset": { + const count = reg.hudElements.length; + if (count === 0) { + out.write(theme.dim("HUD is already clear.\n")); + return; + } + reg.clearHud(); + out.write(theme.cyan("✓") + ` HUD cleared (${count} element${count !== 1 ? "s" : ""} removed)\n`); + break; + } + case "list": + case "ls": + case "show": + case "": { + if (reg.hudElements.length === 0) { + out.write(theme.dim("HUD is empty. /add to add one. /add list to see available.\n")); + return; + } + out.write(theme.iceBlue("Active HUD elements:\n")); + out.write(theme.dim("──────────────────────────────────────────────────────\n")); + for (const id of reg.hudElements) { + const def = HUD_ELEMENTS[id]; + out.write(` ${theme.cyan("●")} ${theme.bold(def.label)} ${theme.dim("(" + def.aliases.join(", ") + ")")}\n`); + } + if (process.stdout.isTTY) { + const cols = process.stdout.columns ?? 80; + const state = buildHudState(reg); + const preview = renderHud(reg.hudElements, state, cols); + if (preview) out.write("\n" + preview + "\n"); + } + break; + } + default: + out.write("usage: /hud remove | /hud list | /hud clear\n"); + break; + } +} + +function buildHudState(reg: ContextRegistry): HudRenderState { + const live = timerLive(reg.hudTimer); + return { + tokensUsed: reg.uvtSpent, + tokensCap: reg.uvtCap ?? 1_000_000_000, + sessionMs: live.userMs + live.agentMs, + timer: reg.hudTimer, + streamedTokens: 0, + uvtUsed: reg.uvtSpent, + uvtCap: reg.uvtCap ?? 0, + }; +} diff --git a/src/commands/slash_media.ts b/src/commands/slash_media.ts new file mode 100644 index 0000000..d7a6d1e --- /dev/null +++ b/src/commands/slash_media.ts @@ -0,0 +1,303 @@ +// ═════════════════════════════════════════════════════════════════════ +// In-REPL media pipeline slash commands: /photogen /frame /re-frame +// /videogen /sequence /animate /re-cut /output /storyboard. +// Split out of slash.ts (was 1807 lines) to keep each command group under +// the repo's ~800-line file convention. +// ═════════════════════════════════════════════════════════════════════ + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { theme } from "../ui/theme.js"; +import { basename } from "node:path"; +import { existsSync as fsExistsSync } from "node:fs"; +import { + resolveModelKey, autoRouteModel, + buildMediaPrompt, dispatchGeneration, downloadMediaFile, + ensureOutputDir, recordOutput, listOutput, findOutput, openOutput, clearOutput, + parseStoryboard, saveStoryboard, loadStoryboard, listStoryboards, + type MediaKind, type GenFlags, type GenResult, +} from "../core/vision.js"; + +// Media pipeline state — persists across turns in the same REPL session. +// Cleared on REPL restart. Used by /re-frame and /re-cut. +let _lastMediaUrl: string | null = null; +let _lastMediaModel: string | null = null; +let _lastMediaKind: MediaKind | null = null; + +function parseSlashFlags(raw: string, _kind: MediaKind): { prompt: string; flags: GenFlags } { + const flags: GenFlags = {}; + const parts = raw.split(/\s+/); + const promptParts: string[] = []; + let inFlags = false; + for (let i = 0; i < parts.length; i++) { + const p = parts[i] ?? ""; + if (!inFlags && p.startsWith("--")) { inFlags = true; } + if (inFlags) { + const next = parts[i + 1] ?? ""; + if (p === "--model" && next) { flags.model = next; i++; } + else if (p === "--aspect" && next) { flags.aspect = next; i++; } + else if ((p === "--count" || p === "--batch") && next) { flags.count = parseInt(next, 10) || 1; i++; } + else if (p === "--4k") { flags.fourK = true; } + else if (p === "--vector") { flags.vector = true; } + else if ((p === "--duration" || p === "--10s") && next) { flags.duration = parseInt(next, 10) || 10; i++; } + else if (p === "--1080p") { flags.hd1080 = true; } + else if (p === "--audio") { flags.audio = true; } + else if (p === "--save-to-vault") { flags.saveToVault = true; } + else if (p === "--ref" && next) { flags.ref = next; i++; } + else if (p === "--open") { flags.open = true; } + } else { + promptParts.push(p); + } + } + return { prompt: promptParts.join(" "), flags }; +} + +const SF = theme.dim; + +export async function photogenSlash(ctx: AppContext, out: Writable, arg: string, _framed: boolean): Promise { + const { prompt, flags } = parseSlashFlags(arg, "image"); + if (!prompt) { + out.write("usage: /photogen [--model nano] [--aspect 16_9] [--count 4] [--4k] [--vector]\n"); + out.write("usage: /frame --aspect \n"); + return; + } + const kind: MediaKind = "image"; + const modelKey = flags.model ? resolveModelKey(flags.model) : autoRouteModel(prompt, kind); + const fullPrompt = buildMediaPrompt(prompt, flags, kind); + const count = Math.min(10, Math.max(1, flags.count ?? 1)); + const outdir = ensureOutputDir(); + out.write(SF(`generating ${count} image(s) with ${modelKey}: "${prompt}"\n\n`)); + for (let i = 0; i < count; i++) { + try { + const vp = count > 1 ? `${fullPrompt} [variant ${i + 1} of ${count}]` : fullPrompt; + out.write(SF(`[${i + 1}/${count}] `)); + const resp = await dispatchGeneration(ctx.api, vp, modelKey, flags); + if (resp.media_url) { + out.write(SF("downloading...\n")); + const filepath = await downloadMediaFile(ctx.api, resp.media_url, outdir, modelKey, kind, resp.filename); + const result: GenResult = { + model: modelKey, prompt: vp, kind, filepath, filename: basename(filepath), + url: resp.media_url, timestamp: new Date().toISOString(), flags, + }; + const entry = recordOutput(result); + out.write(` ${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + out.write(` ${SF(resp.media_url)}\n\n`); + _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaKind = kind; + } else { + out.write(SF(" no media URL returned\n")); + } + } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } + } +} + +export async function reframeSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!_lastMediaUrl || _lastMediaKind !== "image") { out.write(SF(" no previous image to edit\n")); return; } + if (!arg) { out.write("usage: /re-frame \n"); return; } + const editPrompt = `Edit the previously generated image: ${arg}`; + const flags: GenFlags = { model: "edit", ref: _lastMediaUrl }; + out.write(SF(`editing with gpt-image-2: "${arg}"\n\n`)); + try { + const resp = await dispatchGeneration(ctx.api, editPrompt, "vision_gpt_image2", flags); + if (resp.media_url) { + const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_gpt_image2", "image", resp.filename); + const entry = recordOutput({ model: "vision_gpt_image2", prompt: editPrompt, kind: "image", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); + out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_gpt_image2"; + } + } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } +} + +export async function videogenSlash(ctx: AppContext, out: Writable, arg: string, cinematic: boolean): Promise { + const { prompt, flags } = parseSlashFlags(arg, "video"); + if (!prompt) { out.write("usage: /videogen [--model seedance] [--duration 10] [--1080p] [--audio]\n /sequence \n"); return; } + const kind: MediaKind = "video"; + const modelKey = flags.model ? resolveModelKey(flags.model) : cinematic ? "seedance" : autoRouteModel(prompt, kind); + if (!flags.duration) flags.duration = 5; + const fullPrompt = buildMediaPrompt(prompt, flags, kind); + out.write(SF(`generating video with ${modelKey}: "${prompt}"\n\n`)); + try { + const resp = await dispatchGeneration(ctx.api, fullPrompt, modelKey, flags); + if (resp.media_url) { + out.write(SF("downloading video...\n")); + const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, kind, resp.filename); + const entry = recordOutput({ model: modelKey, prompt: fullPrompt, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); + out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n ${SF(resp.media_url)}\n\n`); + _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaKind = kind; + } + } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } +} + +export async function animateSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const args = arg.trim().split(/\s+/); + const ref = args[0]; + const extraPrompt = args.slice(1).join(" "); + if (!ref) { out.write("usage: /animate [motion description]\n"); return; } + let refUrl = ref; + if (/^#?\d+$/.test(ref)) { + const entry = findOutput(ref.replace("#", "")); + if (entry) refUrl = entry.url; + else { out.write(SF(` no output matching "${ref}"\n`)); return; } + } + const prompt = `Animate this image into a fluid video sequence${extraPrompt ? ": " + extraPrompt : ""}`; + out.write(SF(`animating from ${refUrl.slice(0, 50)}...\n\n`)); + try { + const resp = await dispatchGeneration(ctx.api, prompt, "vision_seedance", { model: "seedance", ref: refUrl, duration: 5 }); + if (resp.media_url) { + const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video", resp.filename); + const entry = recordOutput({ model: "vision_seedance", prompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: { model: "seedance", ref: refUrl, duration: 5 } }); + out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_seedance"; _lastMediaKind = "video"; + } + } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } +} + +export async function recutSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!_lastMediaUrl || _lastMediaKind !== "video") { out.write(SF(" no previous video to edit\n")); return; } + if (!arg) { out.write("usage: /re-cut \n"); return; } + const modelKey = _lastMediaModel || "vision_seedance"; + const editPrompt = `Edit the previously generated video: ${arg}`; + out.write(SF(`editing video: "${arg}"\n\n`)); + try { + const resp = await dispatchGeneration(ctx.api, editPrompt, modelKey, { model: modelKey, ref: _lastMediaUrl }); + if (resp.media_url) { + const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, "video", resp.filename); + const entry = recordOutput({ model: modelKey, prompt: editPrompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); + out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + _lastMediaUrl = resp.media_url; + } + } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } +} + +export async function outputSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const parts = arg.split(/\s+/); + const sub = parts[0]?.toLowerCase(); + const ref = parts.slice(1).join(" "); + if (sub === "open" || sub === "o") { + if (!ref) { out.write("usage: /output open \n"); return; } + const entry = findOutput(ref); + if (!entry) { out.write(SF(` no output matching "${ref}"\n`)); return; } + try { openOutput(entry); out.write(`${theme.iceBlue("→")} opened ${entry.filename}\n`); } + catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } + return; + } + if (sub === "clean" || sub === "clear") { out.write(SF(` cleared ${clearOutput()} entries\n`)); return; } + const entries = listOutput(10); + if (!entries.length) { out.write(SF(" (no generations yet — use /photogen or /videogen)\n")); return; } + out.write(`${theme.iceBlue("📦")} RECENT GENERATIONS\n\n`); + for (const e of entries) { + const icon = e.kind === "video" ? "🎬" : e.kind === "3d" ? "🧊" : "🖼"; + const sp = e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt; + out.write(` ${theme.iceBlue("#" + e.index)} ${icon} ${e.filename}\n ${SF(`${e.model} ${(e.size_bytes/1024/1024).toFixed(1)}MB ${sp}`)}\n`); + } + out.write(SF("\n /output open — open in default viewer\n\n")); +} + +export async function storyboardSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const parts = arg.trim().split(/\s+/); + const sub = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + if (sub === "list" || sub === "ls") { + const boards = listStoryboards(); + if (!boards.length) { out.write(SF(" (no saved storyboards)\n")); return; } + out.write(`\n${theme.iceBlue("🎬")} STORYBOARDS\n\n`); + for (const b of boards) out.write(` ${b.id.padEnd(20)} ${b.title.slice(0, 40)} ${b.scenes} scenes [${b.status}]\n`); + return; + } + if (sub === "view" && rest) { + const sb = loadStoryboard(rest); + if (!sb) { out.write(SF(` no storyboard "${rest}"\n`)); return; } + sbPreview(sb, out); + return; + } + if (sub === "--preview" || sub === "--generate" || sub === "--animate" || sub === "--render") { + const boards = listStoryboards(); + const id = rest || (boards[0]?.id ?? ""); + const sb = loadStoryboard(id); + if (!sb) { out.write(SF(" no storyboard found\n")); return; } + const phase = sub.replace("--", ""); + if (phase === "preview") { sbPreview(sb, out); return; } + await sbRender(ctx, sb, phase, out); + return; + } + if (!arg.trim()) { out.write("usage: /storyboard [--scenes n] [--style style]\n /storyboard --preview|--generate|--animate|--render [id]\n /storyboard list|view \n"); return; } + let sourceType: "prompt" | "script_file" = "prompt"; + let source = arg; + let opts: { scenes?: number; style?: string } = {}; + const firstArg = parts[0] ?? ""; + if (fsExistsSync(firstArg) && /\.(md|txt)$/i.test(firstArg)) { + sourceType = "script_file"; source = firstArg; opts = sbFlagParse(parts.slice(1).join(" ")); + } else { + const fi = parts.findIndex(p => p.startsWith("--")); + if (fi > 0) { source = parts.slice(0, fi).join(" "); opts = sbFlagParse(parts.slice(fi).join(" ")); } + } + out.write(SF("parsing storyboard...\n")); + try { + const result = await parseStoryboard(ctx.api, source, sourceType, opts); + const sb = { id: `sb_${Date.now().toString(36)}`, title: result.title, source_type: sourceType, source, style: result.style, total_scenes: result.scenes.length, scenes: result.scenes, created_at: new Date().toISOString(), status: "draft" as const }; + saveStoryboard(sb); + sbPreview(sb, out); + out.write(SF("\n/storyboard --generate to generate all keyframes\n")); + out.write(SF("/storyboard --animate to animate the sequence\n")); + out.write(SF("/storyboard --render full pipeline\n")); + } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } +} + +function sbFlagParse(raw: string): { scenes?: number; style?: string } { + const r: { scenes?: number; style?: string } = {}; + const p = raw.split(/\s+/); + for (let i = 0; i < p.length; i++) { + const next = p[i + 1] ?? ""; + if ((p[i] === "--scenes" || p[i] === "-n") && next) { r.scenes = parseInt(next, 10); i++; } + else if (p[i] === "--style" && next) { r.style = next; i++; } + } + return r; +} + +function sbPreview(sb: any, out: Writable): void { + out.write(`\n${theme.iceBlue("🎬")} STORYBOARD: ${sb.title}\n`); + out.write(SF(` style: ${sb.style} | scenes: ${sb.total_scenes} | status: ${sb.status}\n\n`)); + for (const s of sb.scenes) { + out.write( + `${theme.iceBlue("SCENE " + s.index)} ${(s.shot_type||"WIDE").padEnd(8)} | ${(s.camera_movement||"static").padEnd(10)} | ${s.duration_sec}s | ${s.transition}\n` + + ` ${SF("visual:")} ${(s.keyframe_prompt||"").slice(0,80)}${(s.keyframe_prompt||"").length>80?"...":""}\n` + + ` ${SF("motion:")} ${(s.animation_prompt||"").slice(0,80)}\n` + + ` ${SF("palette:")} ${s.color_palette||"natural"} ${SF("light:")} ${s.lighting||"ambient"}\n\n` + ); + } +} + +async function sbRender(ctx: AppContext, sb: any, phase: string, out: Writable): Promise { + if (phase === "generate" || phase === "render") { + out.write(SF(`generating ${sb.total_scenes} keyframes...\n\n`)); + for (const s of sb.scenes) { + out.write(SF(` scene ${s.index}/${sb.total_scenes}: ${s.shot_type}\n`)); + try { + const mk = autoRouteModel(s.keyframe_prompt, "image"); + const resp = await dispatchGeneration(ctx.api, s.keyframe_prompt, mk, { aspect: "16_9" }); + if (resp.media_url) { + const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), mk, "image"); + s.generated_frame_url = resp.media_url; s.generated_frame_path = fp; + recordOutput({ model: mk, prompt: s.keyframe_prompt, kind: "image", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); + out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`); + } + } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); } + } + sb.status = "keyframes_generated"; saveStoryboard(sb); + } + if (phase === "animate" || phase === "render") { + const anim = sb.scenes.filter((s: any) => s.generated_frame_url); + out.write(SF(`animating ${anim.length} scenes...\n\n`)); + for (const s of anim) { + out.write(SF(` scene ${s.index}: ${s.camera_movement} over ${s.duration_sec}s\n`)); + try { + const resp = await dispatchGeneration(ctx.api, s.animation_prompt, "vision_seedance", { model: "seedance", ref: s.generated_frame_url, duration: s.duration_sec }); + if (resp.media_url) { + const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video"); + recordOutput({ model: "vision_seedance", prompt: s.animation_prompt, kind: "video", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); + out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`); + } + } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); } + } + sb.status = "animated"; saveStoryboard(sb); + } +} diff --git a/src/commands/slash_orchestra.ts b/src/commands/slash_orchestra.ts new file mode 100644 index 0000000..43b8568 --- /dev/null +++ b/src/commands/slash_orchestra.ts @@ -0,0 +1,142 @@ +// In-REPL orchestra slash commands: /agents /delegate /tree /broadcast +// /gather. Split out of slash.ts (was 1807 lines) to keep each command group +// under the repo's ~800-line file convention. + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { AGENTS_PATH } from "../core/transport.js"; +import { theme } from "../ui/theme.js"; +import { delegateWorker, getOrchTree, broadcastToAgents, gatherResults, requireOrchestrator } from "../core/orchestrator.js"; + +// ── Agents slash handler ──────────────────────── + +export async function agentsSlash(ctx: AppContext, out: Writable): Promise { + try { + const resp = await ctx.api.getJson<{ agents?: Array<{ + name?: string; status?: string; working_time?: string; + uvt_streamed?: number; task?: string; + }> }>(AGENTS_PATH); + const agents = resp.agents ?? []; + if (agents.length === 0) { + out.write("(no active agents)\n"); + return; + } + // Columns: name, status, working time, UVT streamed, task + const nameW = Math.max(20, ...agents.map(a => (a.name ?? "?").length)); + const statusW = 12; + const timeW = 14; + const uvtW = 12; + + // Header + const header = " " + + "name".padEnd(nameW) + " " + + "status".padEnd(statusW) + " " + + "time".padEnd(timeW) + " " + + "UVT".padEnd(uvtW) + " " + + "task"; + out.write(theme.bold(header) + "\n"); + out.write(theme.dim(" " + "─".repeat(nameW + statusW + timeW + uvtW + 30)) + "\n"); + + for (const a of agents) { + const name = (a.name ?? "?").padEnd(nameW); + const status = (a.status ?? "?").padEnd(statusW); + const time = (a.working_time ?? "—").padEnd(timeW); + const uvt = a.uvt_streamed != null + ? String(a.uvt_streamed).padEnd(uvtW) + : "—".padEnd(uvtW); + const task = (a.task ?? "").slice(0, 50); + + const statusColor = a.status === "running" ? theme.cyan : + a.status === "complete" ? theme.dim : theme.muted; + + out.write(` ${theme.bold(name)}${statusColor(status)}${theme.dim(time)}${theme.dim(uvt)}${task}\n`); + } + } catch { + out.write("agents: unreachable (are you logged in? aether auth login)\n"); + } +} + +// ── Orchestrator slash handlers ───────────────── + +export async function delegateSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!requireOrchestrator(ctx, out)) return; + const parts = arg.split(/\s+/); + const model = parts[0]; + const task = parts.slice(1).join(" "); + if (!model || !task) { + out.write("usage: /delegate \n"); + return; + } + try { + const r = await delegateWorker(ctx.api, ctx.flags.agent!, model, task); + out.write(`delegated → worker ${r.worker_id} (${r.status}) running ${model}\n`); + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function treeSlash(ctx: AppContext, out: Writable): Promise { + if (!requireOrchestrator(ctx, out)) return; + try { + const r = await getOrchTree(ctx.api, ctx.flags.agent!); + out.write(theme.bold(`orchestrator: ${r.orchestrator}`) + "\n"); + if (r.workers.length === 0) { + out.write(" (no active sub-agents)\n"); + return; + } + // Columns: id, model, step, tokens, UVT + const idW = Math.max(10, ...r.workers.map(w => w.id.length)); + const modelW = Math.max(10, ...r.workers.map(w => w.model.length)); + const stepW = Math.max(12, ...r.workers.map(w => w.step.length)); + const header = " " + + "id".padEnd(idW) + " " + + "model".padEnd(modelW) + " " + + "step".padEnd(stepW) + " " + + "tokens".padEnd(10) + " " + + "UVT"; + out.write(theme.dim(header) + "\n"); + out.write(theme.dim(" " + "─".repeat(idW + modelW + stepW + 30)) + "\n"); + for (const w of r.workers) { + out.write( + ` ${theme.bold(w.id.padEnd(idW))} ` + + `${w.model.padEnd(modelW)} ` + + `${w.step.padEnd(stepW)} ` + + `${String(w.tokens).padEnd(10)} ` + + `${w.uvt}\n` + ); + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function broadcastSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!requireOrchestrator(ctx, out)) return; + const message = arg.trim(); + if (!message) { + out.write("usage: /broadcast \"\"\n"); + return; + } + try { + const r = await broadcastToAgents(ctx.api, ctx.flags.agent!, message); + out.write(`broadcast → delivered to ${r.delivered_to} sub-agents\n`); + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function gatherSlash(ctx: AppContext, out: Writable, arg: string): Promise { + if (!requireOrchestrator(ctx, out)) return; + const workerId = arg.trim(); + if (!workerId) { out.write("usage: /gather \n"); return; } + try { + const r = await gatherResults(ctx.api, ctx.flags.agent!, workerId); + if (r.results.length === 0) { out.write("(no results to gather)\n"); return; } + for (const res of r.results) { + out.write(`${theme.bold(res.worker_id)}:\n`); + if (res.files.length) out.write(` files: ${res.files.join(", ")}\n`); + if (res.diffs.length) out.write(` diffs: ${res.diffs.length} diff(s)\n`); + if (res.patches.length) out.write(` patches: ${res.patches.length} patch(es)\n`); + } + } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); } +} diff --git a/src/commands/slash_vault_workflow.ts b/src/commands/slash_vault_workflow.ts new file mode 100644 index 0000000..309c949 --- /dev/null +++ b/src/commands/slash_vault_workflow.ts @@ -0,0 +1,126 @@ +// In-REPL Vault + Workflow slash commands: /vault-* /workflow*. Split out of +// slash.ts (was 1807 lines) to keep each command group under the repo's +// ~800-line file convention. + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { getVaultSnapshot, searchNotes, notesByTag, getNotesTree } from "../core/vault.js"; +import { WORKFLOW_TEMPLATES, listWorkflows } from "../core/workflow.js"; + +// ── Vault slash handlers ──────────────────────── + +export async function vaultStatusSlash(ctx: AppContext, out: Writable): Promise { + try { + const r = await getVaultSnapshot(ctx.api, 800); + out.write(`vault: ${r.note_count} notes\n`); + } catch { + out.write("vault: unreachable\n"); + } +} + +export async function vaultContextSlash(ctx: AppContext, out: Writable): Promise { + try { + await getVaultSnapshot(ctx.api, 2000); + out.write("vault context loaded for next agent turn.\n"); + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function vaultSearchSlash(ctx: AppContext, out: Writable, query: string): Promise { + if (!query) { out.write("usage: /vault-search \n"); return; } + try { + const r = await searchNotes(ctx.api, query, { limit: 10 }); + if (r.results.length === 0) { out.write("no results.\n"); return; } + for (const n of r.results) { + out.write(` ${n.title || n.path} (${n.path})\n`); + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function vaultRecentSlash(ctx: AppContext, out: Writable, arg: string): Promise { + try { + const n = Math.min(parseInt(arg) || 10, 50); + const r = await searchNotes(ctx.api, "", { limit: n }); + if (r.results.length === 0) { out.write("(empty vault)\n"); return; } + for (const row of r.results) { + out.write(` ${row.title || row.path}\n`); + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function vaultProjectSlash(ctx: AppContext, out: Writable, name: string): Promise { + if (!name) { out.write("usage: /vault-project \n"); return; } + try { + const r = await searchNotes(ctx.api, "", { project: name, limit: 20 }); + if (r.results.length === 0) { out.write(`no notes for project: ${name}\n`); return; } + for (const n of r.results) { + out.write(` ${n.title || n.path} [${n.tags.join(", ")}]\n`); + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function vaultTagSlash(ctx: AppContext, out: Writable, tag: string): Promise { + if (!tag) { out.write("usage: /vault-tag \n"); return; } + try { + const r = await notesByTag(ctx.api, tag, 20); + if (r.results.length === 0) { out.write(`no notes with tag: ${tag}\n`); return; } + for (const n of r.results) { + out.write(` ${n.title || n.path} (${n.path})\n`); + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +export async function vaultTreeSlash(ctx: AppContext, out: Writable): Promise { + try { + const r = await getNotesTree(ctx.api); + if (r.tree.length === 0) { out.write("(empty vault)\n"); return; } + for (const e of r.tree) { + out.write(` ${e.folder || "/"} (${e.count} notes)\n`); + } + } catch (err) { + out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +// ── Workflow slash handlers ───────────────────── + +export async function workflowSlash(ctx: AppContext, out: Writable): Promise { + try { + const workflows = await listWorkflows(ctx.api); + out.write(`workflow: ${workflows.length} in vault · ${WORKFLOW_TEMPLATES.length} templates available\n`); + if (workflows.length > 0) { + for (const w of workflows.slice(0, 5)) out.write(` ${w.name} (${Math.round(w.size/1024)} KB)\n`); + } + } catch { + out.write("workflow: unavailable\n"); + } +} + +export async function workflowTemplatesSlash(ctx: AppContext, out: Writable): Promise { + for (let i = 0; i < WORKFLOW_TEMPLATES.length; i++) { + const t = WORKFLOW_TEMPLATES[i]!; + out.write(` ${String(i+1)}. ${t.icon} ${t.name} (${t.category}, ${t.difficulty})\n`); + } + out.write("load: /workflow-template \n"); +} + +export async function workflowTemplateSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const n = parseInt(arg); + if (isNaN(n) || n < 1 || n > WORKFLOW_TEMPLATES.length) { + out.write(`invalid: ${arg} (1-${WORKFLOW_TEMPLATES.length})\n`); + return; + } + const t = WORKFLOW_TEMPLATES[n - 1]!; + out.write(`${t.icon} ${t.name}: ${t.subtitle}\n`); + out.write(` ${t.workflow.nodes?.length || 0} nodes · ${t.workflow.edges?.length || 0} edges\n`); + out.write(` save with: aether workflow save ${t.id}\n`); +} diff --git a/src/commands/vault.ts b/src/commands/vault.ts index 7cf4ba1..264632d 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -3,6 +3,7 @@ // Upload/download deferred (multipart + raw-binary plumbing needed). import type { AppContext } from "../core/context.js"; +import { fail as coreFail } from "../core/errors.js"; import { getVaultList, browseVault, getSpacesUsage, getSpacesContent, deleteSpacesFile, @@ -42,11 +43,6 @@ export async function cmdVault(ctx: AppContext, argv: string[]): Promise } } -function notYet(feature: string): Promise { - process.stderr.write(`${feature} — coming soon (multipart + raw-binary plumbing needed).\n`); - return Promise.resolve(1); -} - function printVaultHelp(): void { process.stdout.write([ "aether vault list List vault files/folders", @@ -278,7 +274,5 @@ function renderSearchResults(r: VaultSearchResponse, query: string): void { } function fail(err: unknown): number { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`✗ ${msg}\n (are you logged in? run: aether auth login)\n`); - return 1; + return coreFail(err, "are you logged in? run: aether auth login"); } \ No newline at end of file diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index f303afe..1cfdc84 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -2,6 +2,7 @@ // Follows the exact pattern of aether vault and aether github import type { AppContext } from "../core/context.js"; +import { fail as coreFail } from "../core/errors.js"; import { buildChatRequest } from "../core/envelope.js"; import { CHAT_STREAM_PATH } from "../core/transport.js"; import { decodeSse } from "../core/stream.js"; @@ -11,8 +12,7 @@ import { listWorkflows, getWorkflow, deleteWorkflow, saveWorkflow, exportWorkflow, assessWorkflow, brainstormWorkflow, planWorkflow, finalizeWorkflow, formatWorkflowSummary, formatWorkflowDetail, - type Workflow, type TemplateInfo, - type WorkflowAssessResponse, type WorkflowBrainstormResponse, + type Workflow, type WorkflowBrainstormResponse, } from "../core/workflow.js"; export async function cmdWorkflow(ctx: AppContext, argv: string[]): Promise { @@ -41,11 +41,6 @@ export async function cmdWorkflow(ctx: AppContext, argv: string[]): Promise { - process.stderr.write(`${feature} — coming soon (multipart upload/download plumbing needed).\n`); - return Promise.resolve(1); -} - function printWorkflowHelp(): void { process.stdout.write([ "aether workflow new Generate a workflow from intent (AI-driven)", @@ -384,7 +379,5 @@ async function workflowStatus(ctx: AppContext): Promise { // ── Helpers ────────────────────────────────────── function fail(err: unknown): number { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`✗ ${msg}\n (are you logged in? run: aether auth login)\n`); - return 1; + return coreFail(err, "are you logged in? run: aether auth login"); } \ No newline at end of file diff --git a/src/core/errors.ts b/src/core/errors.ts index dd54391..d760d33 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,5 +1,21 @@ // Error taxonomy for the CLI. +/** Unwrap any thrown value to a display message. */ +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Standard command-handler failure path: print `✗ ` (with an + * optional parenthesized hint line, e.g. "are you logged in? run: ...") + * and return the conventional non-zero exit code. + */ +export function fail(err: unknown, hint?: string): number { + const msg = errorMessage(err); + process.stderr.write(`✗ ${msg}\n${hint ? ` (${hint})\n` : ""}`); + return 1; +} + /** Thrown by seams that are scaffolded but not yet wired to the the Aether API. */ export class NotWiredError extends Error { constructor(what: string) { diff --git a/src/core/workflow.ts b/src/core/workflow.ts index 83389ea..696ad8b 100644 --- a/src/core/workflow.ts +++ b/src/core/workflow.ts @@ -6,7 +6,7 @@ import { PROJECT_FROM_WORKFLOW_ASSESS_PATH, PROJECT_FROM_WORKFLOW_BRAINSTORM_PATH, PROJECT_FROM_WORKFLOW_PLAN_PATH, PROJECT_FROM_WORKFLOW_FINALIZE_PATH, } from "./transport.js"; -import { listSpaces, getSpacesContent, deleteSpacesFile, uploadFile, downloadFile, type VaultSpacesFile } from "./vault.js"; +import { listSpaces, getSpacesContent, deleteSpacesFile, uploadFile, downloadFile } from "./vault.js"; // ── Types ─────────────────────────────────────── diff --git a/web-cloud.png b/web-cloud.png deleted file mode 100644 index f672043..0000000 Binary files a/web-cloud.png and /dev/null differ