diff --git a/README.md b/README.md index a0c6f28..4ec58fb 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,25 @@ # Opencode The Token Company Plugin -OpenCode message transform plugin with [The Token Company](https://thetokencompany.com/) (TTC) API. +OpenCode message-transform plugin + sidebar widget for [The Token Company](https://thetokencompany.com/) (TTC, YC W26). -The Token Company (YC W26) builds models that process tokens based on context and semantic intent. With this plugin, you can remove context bloat from your prompts to Opencode before they hit the LLM provider. +TTC compresses LLM prompts by removing low-signal tokens based on context and semantic intent. This plugin wires that into OpenCode so bloated context (long file dumps, verbose tool output, repeated boilerplate) gets shrunk before it hits your LLM provider. You keep the same models, just fewer tokens and faster turns. -Modern OpenCode builds also load a TTC sidebar widget. Compression still runs in the server plugin; the sidebar reads redacted per-session metrics from local state and never stores prompt text, compressed text, request bodies, or API keys. +The sidebar reads redacted per-session metrics from local state. It never stores prompt text, compressed output, request bodies, or API keys. [![npm version](https://img.shields.io/npm/v/@drfok/opencode-ttc-plugin.svg)](https://www.npmjs.com/package/@drfok/opencode-ttc-plugin) +[![npm downloads](https://img.shields.io/npm/dy/@drfok/opencode-ttc-plugin)](https://www.npmjs.com/package/@drfok/opencode-ttc-plugin) [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](./LICENSE) [![X (Twitter)](https://img.shields.io/badge/X-%40drf0k-111111.svg)](https://x.com/drf0k) -[![npm downloads](https://img.shields.io/npm/dy/@drfok/opencode-ttc-plugin)](https://www.npmjs.com/package//@drfok/opencode-ttc-plugin) + +## Requirements + +- OpenCode 1.4.0 or newer (uses the `@opencode-ai/plugin` peer dep) +- Node.js 20 or newer +- A TTC API key from ## 1) Setup -### Option A: Agent-assisted setup +### Option A: Agent-assisted Paste this into your coding agent: @@ -21,153 +27,258 @@ Paste this into your coding agent: Install @drfok/opencode-ttc-plugin by following: https://raw.githubusercontent.com/MrFok/opencode-ttc-plugin/main/README.md ``` -### Option B: Manual setup +### Option B: Manual -1. Install and register plugin: +1. Install and register the plugin: -```bash -npm install -g @drfok/opencode-ttc-plugin -opencode plugin @drfok/opencode-ttc-plugin --global -``` + ```bash + npm install -g @drfok/opencode-ttc-plugin + opencode plugin @drfok/opencode-ttc-plugin --global + ``` -If you are on an older OpenCode build without package plugin target support, use the legacy file installer: + On older OpenCode builds that don't support package plugin targets, fall back to the file installer: -```bash -opencode-ttc-plugin install -``` + ```bash + opencode-ttc-plugin install + ``` -2. Configure auth in OpenCode: +2. Set your TTC API key. Pick whichever path fits: -```bash -opencode auth login -``` + ```bash + # Plugin-managed (writes to the opencode auth store, mode 0o600) + opencode-ttc-plugin login # interactive TTY prompt, echo off + printf '%s' "$TTC_API_KEY" | opencode-ttc-plugin login --stdin # non-interactive / CI -3. In the auth flow choose: -- provider: `the-token-company-plugin` -- method: `Set TTC API Key` -- paste key from `https://thetokencompany.com/` + # Inside opencode (TUI slash commands) + /ttc-login # shows safe login instructions + /ttc-logout # removes a saved key -4. Verify: + # Through opencode's native auth flow (single place to enter the key) + opencode auth login # then pick provider: opencode-ttc-plugin + ``` -```bash -opencode-ttc-plugin doctor --verbose -``` + > **Only one prompt.** Use `opencode auth login` (choose the TTC provider) or the direct CLI. `/ttc-login` does not collect the secret in the TUI because the available prompt dialog displays plain text. + > + > **No `--key ` flag.** Passing a secret on the command line leaks it via `ps aux` and shell history. Use `--stdin` or the TTY prompt. + +3. Verify: + + ```bash + opencode-ttc-plugin doctor --verbose + ``` -5. Restart OpenCode. In a session, the sidebar should include `Token Compression` with the latest message and session savings. Toasts remain as a fallback/configurable summary path. +4. Restart OpenCode. In a session, the sidebar should show `Token Compression` with the live status dot. Once a key is present it reads `authenticated — send a message to start compressing`; you don't have to send a message first to clear stale `missing auth` state. + +### For LLMs installing this on behalf of a user
-For LLMs +Expand for the agent-readable install script + +1. Install the package with npm: + + ```bash + npm install -g @drfok/opencode-ttc-plugin + ``` -1. Install @drfok/opencode-ttc-plugin with npm -'''bash -npm install -g @drfok/opencode-ttc-plugin -''' +2. Place the plugin file into `~/.config/opencode/plugins`: -2. Run the installation command to place the js file into the /.config/opencode/plugins directory -'''bash -opencode-ttc-plugin install -''' + ```bash + opencode-ttc-plugin install + ``` -Finally, tell the user to + 3. Tell the user to set their TTC API key with one of: + - `printf '%s' "$KEY" | opencode-ttc-plugin login --stdin` (CLI, recommended for scripts) + - `opencode-ttc-plugin login` (CLI interactive prompt, echo off) + - `opencode auth login` then choose provider `opencode-ttc-plugin` -1. Run `opencode auth login` -2. Search for `the-token-company-plugin` -3. Enter their TTC API key (obtain from https://thetokencompany.com/) -4. Verify by running `opencode-ttc-plugin doctor --verbose` +4. Tell the user to get a key from if they don't have one. + +5. Verify with `opencode-ttc-plugin doctor --verbose`. + +6. Tell the user to restart OpenCode and watch the sidebar when they start a session. -5. Tell them to restart OpenCode and watch the sidebar when they start a session
-## 2) Configure compression aggressiveness +## 2) Sidebar -Primary control is aggressiveness. You can set it by named level (recommended) or exact numeric value. +The `Token Compression` row in the opencode sidebar shows live per-session state. -Set by level: +**Status dot colors** -```bash -opencode-ttc-plugin config set level balanced -``` +| Color | Meaning | +| --- | --- | +| green | active, skipped, fallback, or no reduction — all normal operating states | +| red | `missing auth` or `disabled` | + +**Status line examples** + +| Status line | What it means | +| --- | --- | +| `active` | Last message compressed successfully | +| `authenticated — send a message to start compressing` | Key present, no message has triggered compression yet (overrides stale `missing_auth` from a previous state file) | +| `skipped: code fence, below threshold` | Last message had parts that were intentionally not compressed, with reason counts | +| `fallback: request failed open` | TTC API errored; original text was passed through unchanged | +| `no reduction` | TTC returned output that wasn't smaller than the input; original used | +| `missing TTC auth` | No API key found — run `opencode-ttc-plugin login` or `opencode auth login` (choose provider `opencode-ttc-plugin`) | +| `disabled` | `enabled: false` in config | +| `waiting for metrics` | State file not written yet (e.g. brand-new session, no messages) | + +The sidebar shows the configured `model` next to the title. The model shown is exactly what gets sent to the TTC API — it cannot change on its own. If it ever looks wrong, see [Troubleshooting](#8-troubleshooting). + +## 3) Slash commands + +| Command | Action | +| --- | --- | +| `/ttc` | Open the Token Compression settings menu (enable/disable, level, aggressiveness, min chars, model, reset) | +| `/ttc-login` | Show safe API-key login instructions (native auth flow or CLI) | +| `/ttc-logout` | Remove the TTC API key (asks for confirmation) | + +The `/ttc` menu shows one auth row: `Add API key` when no saved key exists, or `Remove API key` when a key is saved. `Add API key` opens the same safe instructions as `/ttc-login`; it does not render a visible secret input. + +The model picker is curated from the TTC docs (`bear-2`, `bear-1.2`) plus a `custom model id` escape hatch for enterprise fine-tunes. See [Models](#6-models). -Set exact value: +## 4) CLI reference ```bash -opencode-ttc-plugin config set aggressiveness 0.25 +opencode-ttc-plugin install # place plugin file in ~/.config/opencode/plugins +opencode-ttc-plugin uninstall # remove the installed plugin file +opencode-ttc-plugin doctor # setup/auth checks +opencode-ttc-plugin doctor --verbose # show effective config sources + known models + sidebar state path +opencode-ttc-plugin login # interactive TTY prompt (echo off), writes key with mode 0o600 +opencode-ttc-plugin login --stdin # read key from stdin — use for CI: printf '%s' "$KEY" | opencode-ttc-plugin login --stdin +opencode-ttc-plugin logout # remove TTC auth entries (current and legacy IDs) +opencode-ttc-plugin config get # print plugin config + effective aggressiveness +opencode-ttc-plugin config set level # low | balanced | high | max +opencode-ttc-plugin config set aggressiveness # 0..1 numeric +opencode-ttc-plugin config set # any behavior setting from the table below +opencode-ttc-plugin config reset # remove plugin config file ``` -Inspect active config: +## 5) Configuration + +All config is optional. Defaults work for most users. + +### Aggressiveness + +The main knob. Higher = more tokens removed. ```bash -opencode-ttc-plugin config get -opencode-ttc-plugin doctor --verbose +opencode-ttc-plugin config set level balanced # recommended +opencode-ttc-plugin config set aggressiveness 0.15 # exact numeric override ``` -Compression levels: +| Level | Aggressiveness | Typical use | +| --- | --- | --- | +| `low` | `0.05` | Minimal changes — financial, legal, medical | +| `balanced` | `0.10` | **Default.** Good savings with stable quality | +| `high` | `0.20` | Stronger compression | +| `max` | `0.30` | Most aggressive preset | + +Per TTC's docs (`https://thetokencompany.com/docs/compression`): +- `0.05–0.15` Light — financial reports, legal contracts, medical records +- `0.15–0.4` Moderate — meeting transcripts, call recordings, scraped page content +- `0.4–0.9` Aggressive — chat history summarization, replacing compact + +**Resolution order:** `TTC_AGGRESSIVENESS` env → `~/.config/opencode/ttc-plugin.json` → built-in default (`balanced` = `0.1`). + +### Behavior settings + +CLI config is the normal path; env vars are advanced overrides. + +| Setting | Default | What it does | CLI | +| --- | --- | --- | --- | +| `enabled` | `true` | Master on/off switch for the transform hook | `config set enabled true` | +| `model` | `bear-1.2` | TTC model sent to `/v1/compress` (see [Models](#6-models)) | `config set model bear-1.2` | +| `minChars` | `400` | Skip compression for text shorter than this | `config set min-chars 400` | +| `timeoutMs` | `2000` | Per-request timeout | `config set timeout-ms 2000` | +| `maxRetries` | `1` | Retry count for retryable TTC failures (429, 5xx) | `config set max-retries 1` | +| `retryBackoffMs` | `100` | Backoff base between retries (linear) | `config set retry-backoff-ms 100` | +| `useGzip` | `true` | gzip the request body to TTC | `config set use-gzip true` | +| `compressSystem` | `false` | Also compress eligible `system` messages | `config set compress-system false` | +| `compressHistory` | `false` | Also compress older `user` turns, not just the latest | `config set compress-history false` | +| `debug` | `false` | Emit extra plugin debug logs | `config set debug false` | +| `cacheMaxEntries` | `1000` | Max in-memory dedupe cache entries | `config set cache-max-entries 1000` | +| `toastOnActive` | `false` | One activation toast per session (sidebar is primary UI) | `config set toast-on-active true` | +| `toastOnIdleSummary` | `false` | Idle summary toast with savings stats | `config set toast-on-idle-summary true` | + +The only TTC API parameters this plugin sends are `model` and `compression_settings.aggressiveness`. Everything else is plugin-side control. + +**Env overrides (optional):** `TTC_ENABLED`, `TTC_MODEL`, `TTC_MIN_CHARS`, `TTC_TIMEOUT_MS`, `TTC_MAX_RETRIES`, `TTC_RETRY_BACKOFF_MS`, `TTC_USE_GZIP`, `TTC_COMPRESS_SYSTEM`, `TTC_COMPRESS_HISTORY`, `TTC_DEBUG`, `TTC_CACHE_MAX_ENTRIES`, `TTC_TOAST_ON_ACTIVE`, `TTC_TOAST_ON_IDLE_SUMMARY`, `TTC_AGGRESSIVENESS`. + +## 6) Models + +Per : -| Level | Aggressiveness | Typical tradeoff | +| Model | Status | Notes | | --- | --- | --- | -| `low` | `0.05` | Minimal changes, conservative compression | -| `balanced` | `0.10` | Default; good savings with stable quality | -| `high` | `0.20` | Stronger compression, better token reduction | -| `max` | `0.30` | Most aggressive preset in this plugin | +| `bear-2` | **Recommended** | Most accurate compression. Best quality preservation. | +| `bear-1.2` | Available | Faster compression. Lower latency per request. | -Why these values exist: -- TTC API exposes aggressiveness on a `0.0-1.0` range in their docs: `https://thetokencompany.com/docs` -- TTC benchmark data shows quality/token tradeoffs vary by aggressiveness: `https://www.thetokencompany.com/benchmarks/accuracy` +This plugin's default is `bear-1.2` to preserve existing behavior. To switch: `/ttc` → `Model` → `bear-2 (Recommended)`, or `opencode-ttc-plugin config set model bear-2`. -Runtime resolution order for aggressiveness: -1. `TTC_AGGRESSIVENESS` env var (override) -2. plugin config file `~/.config/opencode/ttc-plugin.json` -3. built-in default (`balanced` = `0.1`) +There is no TTC API endpoint for listing models at runtime, so the `/ttc` picker is curated from the docs above plus a `custom model id` escape hatch for enterprise fine-tunes. -## 3) CLI commands +## 7) What gets compressed -| Command | What it does | +**Selection:** +- By default, only the latest `user` turn is compressed (avoids re-compressing unchanged history every turn). +- `compressSystem: true` adds eligible `system` messages. +- `compressHistory: true` adds older `user` turns. +- `assistant` messages are never compressed. + +**Skip patterns** (a part that matches is passed through unchanged): + +| Reason | What triggers it | | --- | --- | -| `opencode-ttc-plugin install` | Installs plugin file into `~/.config/opencode/plugins` | -| `opencode-ttc-plugin doctor` | Runs setup/auth checks | -| `opencode-ttc-plugin doctor --verbose` | Shows effective config sources, TUI entrypoint status, and sidebar state path | -| `opencode-ttc-plugin uninstall` | Removes installed plugin file | -| `opencode-ttc-plugin config get` | Prints plugin config and effective aggressiveness | -| `opencode-ttc-plugin config set level ` | Sets named aggressiveness level | -| `opencode-ttc-plugin config set aggressiveness <0..1>` | Sets numeric aggressiveness | -| `opencode-ttc-plugin config set ` | Sets behavior settings (see table below) | -| `opencode-ttc-plugin config reset` | Removes plugin config file | +| `below threshold` | Text shorter than `minChars` | +| `code fence` | Contains a `` ``` `` fence | +| `diff` | Looks like a `diff --git` blob, `+++`/`---`/`@@` hunks | +| `stack trace` | Python-style traceback or `Exception:` line | +| `JSON` | Whole message is a JSON object or array | +| `schema` | JSON Schema markers (`$schema`, `tool_calls`, `properties`, etc.) | +| `synthetic` | Opencode-internal synthetic parts | +| `empty` / `non-text` | Empty string or non-text part | -## Behavior settings +These protect content where compression could mangle semantics. They are hardcoded; there is no per-pattern toggle today. -Use CLI config for normal setup. Env vars are advanced overrides. +## 8) Troubleshooting -| Setting | Default | What it does | CLI command | -| --- | --- | --- | --- | -| `enabled` | `true` | Master on/off switch for the transform hook | `opencode-ttc-plugin config set enabled true` | -| `model` | `bear-1.2` | TTC model sent to `/v1/compress` | `opencode-ttc-plugin config set model bear-1.2` | -| `minChars` | `400` | Skip compression for text shorter than this | `opencode-ttc-plugin config set min-chars 400` | -| `timeoutMs` | `2000` | Request timeout per TTC call | `opencode-ttc-plugin config set timeout-ms 2000` | -| `maxRetries` | `1` | Retry count for retryable TTC failures | `opencode-ttc-plugin config set max-retries 1` | -| `retryBackoffMs` | `100` | Backoff base between retries | `opencode-ttc-plugin config set retry-backoff-ms 100` | -| `useGzip` | `true` | Sends compressed request body to TTC | `opencode-ttc-plugin config set use-gzip true` | -| `compressSystem` | `false` | Also compresses eligible `system` messages in context | `opencode-ttc-plugin config set compress-system false` | -| `compressHistory` | `false` | Also compresses older eligible `user` history messages (not just latest user turn) | `opencode-ttc-plugin config set compress-history false` | -| `debug` | `false` | Emits extra plugin debug logs | `opencode-ttc-plugin config set debug false` | -| `cacheMaxEntries` | `1000` | Max in-memory dedupe cache entries | `opencode-ttc-plugin config set cache-max-entries 1000` | -| `toastOnActive` | `false` | Shows one activation toast per session when enabled; sidebar is the primary UI | `opencode-ttc-plugin config set toast-on-active true` | -| `toastOnIdleSummary` | `false` | Shows idle summary toast with savings stats when enabled; sidebar is the primary UI | `opencode-ttc-plugin config set toast-on-idle-summary true` | - -Notes on scope: -- TTC API parameters used directly by this plugin request are primarily `model` and `compression_settings.aggressiveness`. -- Most settings above are plugin-side controls (selection, retries, skipping, caching, and UX behavior). -- For TTC API details, see `https://thetokencompany.com/docs`. - -Advanced overrides (optional): -- `TTC_AGGRESSIVENESS`, `TTC_MIN_CHARS`, `TTC_TIMEOUT_MS`, `TTC_MAX_RETRIES`, `TTC_RETRY_BACKOFF_MS` -- `TTC_USE_GZIP`, `TTC_COMPRESS_SYSTEM`, `TTC_COMPRESS_HISTORY`, `TTC_DEBUG` -- `TTC_CACHE_MAX_ENTRIES`, `TTC_TOAST_ON_ACTIVE`, `TTC_TOAST_ON_IDLE_SUMMARY`, `TTC_MODEL`, `TTC_ENABLED` - -## Security and network policy - -- Compression egress is pinned to `https://api.thetokencompany.com/v1/compress`. -- Custom/invalid `TTC_BASE_URL` is ignored and safely falls back to pinned host. -- Fetch redirects are rejected. -- Sidebar state is written under `${XDG_STATE_HOME:-~/.local/state}/opencode/ttc-plugin` with hashed session filenames. -- Sidebar state contains aggregate counts and token/character savings only; it does not persist prompts, compressed output, request bodies, or API keys. -- If your firewall prompts about outbound socket traffic, that is expected on first compression request. +**`missing TTC auth` after I logged in** +The sidebar polls the opencode auth store every 2s, so it should clear on its own. If it doesn't, run `opencode-ttc-plugin doctor` and confirm the key is detected under either `opencode-ttc-plugin` (new) or `the-token-company-plugin` (legacy) — both are read. + +**Sidebar shows a model I didn't configure (e.g. `bear-2.0`)** +The sidebar shows exactly what the plugin is configured to send. `bear-2.0` is **not** a real model id. Common causes: +- A `model` key left in `~/.config/opencode/ttc-plugin.json` from a previous `/ttc` selection +- `TTC_MODEL` exported in your shell +- A typo from the old free-text prompt + +Run `opencode-ttc-plugin doctor --verbose` to see the effective `model` and its source. Reset with `opencode-ttc-plugin config reset`, then re-pick from the curated list via `/ttc`. + +**`opencode auth login` keeps asking for two keys** +That's expected — one is your LLM provider key (OpenAI/Anthropic/etc), the other is the TTC key. They're separate flows. Use `opencode-ttc-plugin login` (CLI) or `opencode auth login` → pick provider `opencode-ttc-plugin`. There is no separate custom prompt in the TUI. + +**Compression isn't firing** +Check the sidebar status line. If it says `skipped: ...`, the message matched a [skip pattern](#7-what-gets-compressed) or was below `minChars`. Lower `minChars` (`config set min-chars 100`) or check that your message isn't mostly code/JSON. + +**Sidebar metrics reset after switching sessions or restarting OpenCode** +Metrics are keyed by OpenCode session id and persisted under the hashed state path shown by `opencode-ttc-plugin doctor --verbose`. Switching sessions may show `new session` or `waiting for first compression` for sessions TTC has not processed yet, but returning to a processed session should restore its sidebar totals. After restarting OpenCode, the next compression in that session hydrates from the persisted state before writing new totals, so cumulative metrics should not reset. + +**Doctor says `auth store ... set under 'the-token-company-plugin'`** +That's the legacy provider id from before v0.1.5. It still works — the plugin reads both. Your next `login` writes under the new `opencode-ttc-plugin` id and also clears the legacy entry; you can `logout` once to clear the legacy entry if you want a clean store. + +**A non-TTC provider key disappeared from `auth.json`** +The auth store is written with atomic temp+rename (no partial files) but is not serialized across concurrent writers. If you ran `opencode-ttc-plugin login` at the exact moment `opencode auth login` was rotating another provider's key, the slower writer wins and the other's update can be dropped. Rare in practice (both are user-initiated, single-shot). Recovery: re-enter the dropped key via `opencode auth login`. + +## 9) Security and network policy + +- **Egress pinned** to `https://api.thetokencompany.com/v1/compress`. Custom/invalid `TTC_BASE_URL` is ignored. +- **Redirects rejected.** Fetch follows no redirects. +- **Auth store writes are atomic.** Temp file + `rename(2)`, file mode `0o600`, directory mode `0o700`. A corrupt existing file is refused, never silently overwritten. A symlink at the target path is replaced, not followed, so a planted symlink can't exfiltrate the key. +- **No key in argv.** `login` never accepts `--key ` (would leak via `ps aux` and shell history). Use the TTY prompt (echo off) or `--stdin`. +- **XDG fallback.** A relative or non-absolute `XDG_DATA_HOME` is ignored and falls back to `$HOME/.local/share` to prevent the key being written to the working directory. +- **Sidebar state is metadata only.** Written under `${XDG_STATE_HOME:-~/.local/state}/opencode/ttc-plugin` with hashed session filenames. Contains aggregate counts and token/character savings only — no prompts, compressed output, request bodies, or API keys. +- **First compression request may trip a firewall prompt.** That's expected outbound traffic to `api.thetokencompany.com`. + +## License + +ISC — see [LICENSE](./LICENSE). diff --git a/lib/auth-store.js b/lib/auth-store.js new file mode 100644 index 0000000..0c84d71 --- /dev/null +++ b/lib/auth-store.js @@ -0,0 +1,217 @@ +import { readFile, writeFile, rename, mkdir, rm, chmod, lstat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { randomBytes } from "node:crypto"; + +export const AUTH_PROVIDER_ID = "opencode-ttc-plugin"; +export const LEGACY_AUTH_PROVIDER_IDS = ["the-token-company-plugin"]; +const AUTH_FILE_MODE = 0o600; +const AUTH_DIR_MODE = 0o700; + +export function getAuthStorePath(env = process.env) { + const raw = String(env.XDG_DATA_HOME ?? "").trim(); + if (raw && isAbsolute(raw)) { + return join(raw, "opencode", "auth.json"); + } + return join(homedir(), ".local", "share", "opencode", "auth.json"); +} + +export async function readAuthStore({ + authFilePath = getAuthStorePath(), + readFileImpl = readFile, + lstatImpl = lstat +} = {}) { + let stats; + try { + stats = await lstatImpl(authFilePath); + } catch (error) { + if (error?.code === "ENOENT") return { parsed: {}, source: "missing" }; + throw error; + } + if (!stats.isFile()) { + const err = new Error("auth store path is not a regular file"); + err.code = "AUTH_STORE_NOT_REGULAR_FILE"; + throw err; + } + + const content = await readFileImpl(authFilePath, "utf8"); + const trimmed = String(content ?? "").trim(); + if (!trimmed) return { parsed: {}, source: "empty" }; + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (error) { + const err = new Error("auth store JSON is corrupt and cannot be parsed"); + err.code = "AUTH_STORE_CORRUPT"; + err.cause = error; + err.rawContent = trimmed; + throw err; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + const err = new Error("auth store JSON is not an object"); + err.code = "AUTH_STORE_NOT_OBJECT"; + throw err; + } + return { parsed, source: "parsed" }; +} + +export function findAuthEntry(parsed, providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS]) { + if (!parsed || typeof parsed !== "object") return null; + for (const candidateID of providerIDs) { + const auth = parsed[candidateID]; + if (auth && typeof auth === "object" && auth.type === "api" && String(auth.key ?? "").trim()) { + return { providerID: candidateID, key: String(auth.key).trim() }; + } + } + return null; +} + +export async function hasAuthEntry({ + authFilePath = getAuthStorePath(), + providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS], + env = process.env, + readFileImpl = readFile, + lstatImpl = lstat +} = {}) { + // TTC_API_KEY in env is a supported first-class auth source + if (String(env.TTC_API_KEY ?? "").trim()) { + return { hasKey: true, providerID: "env" }; + } + try { + const { parsed } = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); + const entry = findAuthEntry(parsed, providerIDs); + return { hasKey: Boolean(entry), providerID: entry?.providerID ?? null }; + } catch { + return { hasKey: false, providerID: null }; + } +} + +async function atomicWriteJson(authFilePath, parsed, { + writeFileImpl = writeFile, + renameImpl = rename, + mkdirImpl = mkdir, + chmodImpl = chmod, + rmImpl = rm +} = {}) { + await mkdirImpl(dirname(authFilePath), { recursive: true, mode: AUTH_DIR_MODE }); + try { + await chmodImpl(dirname(authFilePath), AUTH_DIR_MODE); + } catch { + // best-effort; some filesystems ignore directory chmod + } + + const suffix = `${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}`; + const tempPath = `${authFilePath}.${suffix}.tmp`; + const body = `${JSON.stringify(parsed, null, 2)}\n`; + try { + await writeFileImpl(tempPath, body, { encoding: "utf8", mode: AUTH_FILE_MODE }); + await chmodImpl(tempPath, AUTH_FILE_MODE); + await renameImpl(tempPath, authFilePath); + try { + await chmodImpl(authFilePath, AUTH_FILE_MODE); + } catch { + // best-effort; rename may have already applied the mode from the temp file + } + } finally { + await rmImpl(tempPath, { force: true }).catch(() => {}); + } +} + +export async function writeAuthEntry({ + apiKey, + providerID = AUTH_PROVIDER_ID, + legacyProviderIDs = LEGACY_AUTH_PROVIDER_IDS, + authFilePath = getAuthStorePath(), + readFileImpl = readFile, + writeFileImpl = writeFile, + renameImpl = rename, + mkdirImpl = mkdir, + chmodImpl = chmod, + rmImpl = rm, + lstatImpl = lstat +} = {}) { + const trimmed = String(apiKey ?? "").trim(); + if (!trimmed) return { ok: false, reason: "empty_key", authFilePath }; + + let parsed; + try { + const result = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); + parsed = result.parsed; + } catch (error) { + if (error?.code === "AUTH_STORE_CORRUPT" || error?.code === "AUTH_STORE_NOT_OBJECT") { + return { ok: false, reason: "auth_store_corrupt", authFilePath, error }; + } + if (error?.code === "AUTH_STORE_NOT_REGULAR_FILE") { + return { ok: false, reason: "auth_store_not_regular_file", authFilePath, error }; + } + return { ok: false, reason: "auth_store_read_failed", authFilePath, error }; + } + + const removedLegacyIDs = []; + for (const candidateID of legacyProviderIDs) { + if (candidateID === providerID) continue; + if (Object.prototype.hasOwnProperty.call(parsed, candidateID)) { + delete parsed[candidateID]; + removedLegacyIDs.push(candidateID); + } + } + + parsed[providerID] = { type: "api", key: trimmed }; + + try { + await atomicWriteJson(authFilePath, parsed, { writeFileImpl, renameImpl, mkdirImpl, chmodImpl, rmImpl }); + } catch (error) { + return { ok: false, reason: "auth_store_write_failed", authFilePath, error }; + } + + return { ok: true, authFilePath, providerID, removedLegacyIDs }; +} + +export async function removeAuthEntry({ + providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS], + authFilePath = getAuthStorePath(), + readFileImpl = readFile, + writeFileImpl = writeFile, + renameImpl = rename, + mkdirImpl = mkdir, + chmodImpl = chmod, + rmImpl = rm, + lstatImpl = lstat +} = {}) { + let parsed; + try { + const result = await readAuthStore({ authFilePath, readFileImpl, lstatImpl }); + parsed = result.parsed; + } catch (error) { + if (error?.code === "ENOENT") { + return { ok: true, authFilePath, removedAny: false }; + } + if (error?.code === "AUTH_STORE_NOT_REGULAR_FILE") { + return { ok: false, reason: "auth_store_not_regular_file", authFilePath, error }; + } + if (error?.code === "AUTH_STORE_CORRUPT" || error?.code === "AUTH_STORE_NOT_OBJECT") { + return { ok: false, reason: "auth_store_corrupt", authFilePath, error }; + } + return { ok: false, reason: "auth_store_read_failed", authFilePath, error }; + } + + let removedAny = false; + for (const candidateID of providerIDs) { + if (Object.prototype.hasOwnProperty.call(parsed, candidateID)) { + delete parsed[candidateID]; + removedAny = true; + } + } + + if (!removedAny) return { ok: true, authFilePath, removedAny: false }; + + try { + await atomicWriteJson(authFilePath, parsed, { writeFileImpl, renameImpl, mkdirImpl, chmodImpl, rmImpl }); + } catch (error) { + return { ok: false, reason: "auth_store_write_failed", authFilePath, error }; + } + + return { ok: true, authFilePath, removedAny: true }; +} diff --git a/opencode-plugins/ttc-message-transform-core.js b/opencode-plugins/ttc-message-transform-core.js index 3244a8b..166dd4e 100644 --- a/opencode-plugins/ttc-message-transform-core.js +++ b/opencode-plugins/ttc-message-transform-core.js @@ -62,6 +62,10 @@ export function createSessionStats(...args) { return testApi.createSessionStats(...args); } +export function hydrateSessionStatsFromSidebarState(...args) { + return testApi.hydrateSessionStatsFromSidebarState(...args); +} + export function resetLastMessageStats(...args) { return testApi.resetLastMessageStats(...args); } diff --git a/opencode-plugins/ttc-message-transform.js b/opencode-plugins/ttc-message-transform.js index 19efc99..33c15b4 100644 --- a/opencode-plugins/ttc-message-transform.js +++ b/opencode-plugins/ttc-message-transform.js @@ -1,10 +1,11 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { gzipSync } from "node:zlib"; -const AUTH_PROVIDER_ID = "the-token-company-plugin"; +const AUTH_PROVIDER_ID = "opencode-ttc-plugin"; +const LEGACY_AUTH_PROVIDER_IDS = ["the-token-company-plugin"]; const LOCKED_BASE_URL = "https://api.thetokencompany.com"; const COMPRESSION_LEVELS = { @@ -212,12 +213,66 @@ function createSessionStats() { }; } -function getSessionStats(sessionStats, sessionID) { +function hydrateSessionStatsFromSidebarState(state) { + if (!state || typeof state !== "object") return null; + if (state.schemaVersion !== 1) return null; + + const numberOrZero = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + }; + const stats = createSessionStats(); + const session = state.session && typeof state.session === "object" ? state.session : {}; + const lastMessage = state.lastMessage && typeof state.lastMessage === "object" ? state.lastMessage : {}; + + stats.processed = numberOrZero(session.processed); + stats.compressed = numberOrZero(session.compressed); + stats.skipped = numberOrZero(session.skipped); + stats.fallback = numberOrZero(session.fallback); + stats.cacheHits = numberOrZero(session.cacheHits); + stats.charsBefore = numberOrZero(session.charsBefore); + stats.charsAfter = numberOrZero(session.charsAfter); + stats.estimatedTokensSaved = numberOrZero(session.estimatedTokensSaved); + stats.exactTokensSaved = numberOrZero(session.exactTokensSaved); + stats.lastMessageCharsBefore = numberOrZero(lastMessage.charsBefore); + stats.lastMessageCharsAfter = numberOrZero(lastMessage.charsAfter); + stats.lastMessageTokensSaved = numberOrZero(lastMessage.tokensSaved); + stats.lastMessagePartsProcessed = numberOrZero(lastMessage.partsProcessed); + stats.lastMessageCompressed = Boolean(lastMessage.compressed); + stats.lastMessageNoReduction = Boolean(lastMessage.noReduction); + stats.lastMessageFallback = Boolean(lastMessage.fallback); + stats.lastMessageSkipReasons = lastMessage.skipReasons && typeof lastMessage.skipReasons === "object" + ? Object.fromEntries(Object.entries(lastMessage.skipReasons).map(([reason, count]) => [reason, numberOrZero(count)])) + : {}; + stats.version = stats.processed + stats.skipped + stats.fallback > 0 ? 1 : 0; + + return stats; +} + +async function loadPersistedSessionStats(sessionID, { + statePath = getSidebarStatePath(sessionID), + readFileImpl = readFile +} = {}) { + try { + const content = await readFileImpl(statePath, "utf8"); + return hydrateSessionStatsFromSidebarState(JSON.parse(content)); + } catch { + return null; + } +} + +async function getSessionStats(sessionStats, sessionID, options = {}) { if (!sessionStats || !sessionID) return null; - if (!sessionStats.has(sessionID)) { - sessionStats.set(sessionID, createSessionStats()); + const existing = sessionStats.get(sessionID); + if (existing) return await existing; + + const pendingStats = loadPersistedSessionStats(sessionID, options).then((stats) => stats ?? createSessionStats()); + sessionStats.set(sessionID, pendingStats); + const stats = await pendingStats; + if (sessionStats.get(sessionID) === pendingStats) { + sessionStats.set(sessionID, stats); } - return sessionStats.get(sessionID); + return stats; } function updateStatsVersion(stats) { @@ -433,9 +488,11 @@ function buildTtcPluginConfig(env = process.env) { } function getAuthStorePath(env = process.env) { - const xdgDataHome = String(env.XDG_DATA_HOME ?? "").trim(); - const dataHome = xdgDataHome || join(homedir(), ".local", "share"); - return join(dataHome, "opencode", "auth.json"); + const raw = String(env.XDG_DATA_HOME ?? "").trim(); + if (raw && isAbsolute(raw)) { + return join(raw, "opencode", "auth.json"); + } + return join(homedir(), ".local", "share", "opencode", "auth.json"); } function getPluginConfigPath(env = process.env) { @@ -709,23 +766,28 @@ function resolveBehaviorConfig({ async function resolveApiKeyFromAuthStore({ providerID = AUTH_PROVIDER_ID, + legacyProviderIDs = LEGACY_AUTH_PROVIDER_IDS, authFilePath = getAuthStorePath(), readFileImpl = readFile } = {}) { + let parsed; try { const content = await readFileImpl(authFilePath, "utf8"); - const parsed = JSON.parse(content); - if (!parsed || typeof parsed !== "object") return ""; - - const auth = parsed[providerID]; - if (!auth || typeof auth !== "object") return ""; - if (auth.type !== "api") return ""; - - const key = String(auth.key ?? "").trim(); - return key; + parsed = JSON.parse(content); } catch { return ""; } + if (!parsed || typeof parsed !== "object") return ""; + + const candidateIDs = [providerID, ...legacyProviderIDs]; + for (const candidateID of candidateIDs) { + const auth = parsed[candidateID]; + if (!auth || typeof auth !== "object") continue; + if (auth.type !== "api") continue; + const key = String(auth.key ?? "").trim(); + if (key) return key; + } + return ""; } function resolveEffectiveApiKey(envApiKey, authStoreApiKey) { @@ -936,6 +998,8 @@ async function transformMessagesWithTtc({ cache, sessionStats = null, authSource = "unknown", + readSidebarStateImpl = readFile, + env = process.env, writeSidebarStateImpl = writeSidebarState, fetchImpl = fetch }) { @@ -946,12 +1010,17 @@ async function transformMessagesWithTtc({ const latestUserMessageID = latestUser?.info?.id; const latestUserSessionID = latestUser?.info?.sessionID ?? currentSessionID; if (!config.enabled || !config.apiKey) { - const stats = getSessionStats(sessionStats, latestUserSessionID); + const statePath = getSidebarStatePath(latestUserSessionID, env); + const stats = await getSessionStats(sessionStats, latestUserSessionID, { + statePath, + readFileImpl: readSidebarStateImpl + }); await writeSidebarStateImpl({ stats, config, sessionID: latestUserSessionID, - authSource + authSource, + statePath }); return; } @@ -963,7 +1032,11 @@ async function transformMessagesWithTtc({ if (!shouldCompressMessage(messageEntry.info, latestUserMessageID, config)) continue; const sessionID = messageEntry.info.sessionID ?? currentSessionID; - const stats = getSessionStats(sessionStats, sessionID); + const statePath = getSidebarStatePath(sessionID, env); + const stats = await getSessionStats(sessionStats, sessionID, { + statePath, + readFileImpl: readSidebarStateImpl + }); if (stats && !resetSessions.has(sessionID)) { resetSessions.add(sessionID); resetLastMessageStats(stats); @@ -1080,7 +1153,8 @@ async function transformMessagesWithTtc({ stats, config, sessionID, - authSource + authSource, + statePath }); } } @@ -1109,6 +1183,7 @@ const TtcMessageTransformPlugin = async ({ client }) => { enabled: initialConfig.enabled, has_api_key: Boolean(initialConfig.apiKey), auth_provider_id: AUTH_PROVIDER_ID, + legacy_auth_provider_ids: LEGACY_AUTH_PROVIDER_IDS, auth_source: initialApiKeyResolution.source, base_url_source: initialConfig.baseUrlSource, behavior_sources: initialBehaviorResolution.sources, @@ -1134,20 +1209,7 @@ const TtcMessageTransformPlugin = async ({ client }) => { methods: [ { type: "api", - label: "Set TTC API Key", - prompts: [ - { - type: "text", - key: "apiKey", - message: "Enter TTC API key", - placeholder: "ttc_..." - } - ], - async authorize(inputs = {}) { - const key = String(inputs.apiKey ?? "").trim(); - if (!key) return { type: "failed" }; - return { type: "success", key }; - } + label: "The Token Company (TTC) API key" } ] }, @@ -1191,6 +1253,7 @@ export default TtcMessageTransformPlugin; TtcMessageTransformPlugin._test = { AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, COMPRESSION_LEVELS, buildTtcPluginConfig, getPluginConfigPath, @@ -1206,6 +1269,7 @@ TtcMessageTransformPlugin._test = { resolveEffectiveApiKey, resolveSessionIDFromTransformInput, createSessionStats, + hydrateSessionStatsFromSidebarState, resetLastMessageStats, recordSkipReason, recordProcessedPart, diff --git a/package.json b/package.json index 5b77a0c..da0f004 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@drfok/opencode-ttc-plugin", - "version": "0.1.4", + "version": "0.1.5", "private": false, "type": "module", "description": "OpenCode plugin that allows input compression via The Token Company API", @@ -33,6 +33,7 @@ "files": [ "opencode-plugins/ttc-message-transform.js", "opencode-plugins/ttc-message-transform-core.js", + "lib/auth-store.js", "tui/index.tsx", "tui/settings.js", "tui/sidebar-state.js", diff --git a/scripts/cli.mjs b/scripts/cli.mjs index e1a47a7..1a626c1 100755 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -12,10 +12,23 @@ import { import { homedir } from "node:os"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import readline from "node:readline"; +import { Writable } from "node:stream"; +import { + AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, + getAuthStorePath, + hasAuthEntry, + writeAuthEntry, + removeAuthEntry +} from "../lib/auth-store.js"; const PLUGIN_FILENAME = "ttc-message-transform.js"; -const AUTH_PROVIDER_ID = "the-token-company-plugin"; const DEFAULT_AGGRESSIVENESS = 0.1; +const KNOWN_MODELS = [ + { id: "bear-2", description: "Most accurate compression. Recommended per TTC docs." }, + { id: "bear-1.2", description: "Faster compression. Lower latency per request." } +]; const COMPRESSION_LEVELS = { low: 0.05, balanced: 0.1, @@ -63,12 +76,6 @@ const tuiEntrypointPath = resolve(repoRoot, "tui", "index.tsx"); const pluginsDir = resolve(homedir(), ".config", "opencode", "plugins"); const installedPluginPath = resolve(pluginsDir, PLUGIN_FILENAME); -function getAuthStorePath() { - const xdgDataHome = String(process.env.XDG_DATA_HOME ?? "").trim(); - const dataHome = xdgDataHome || resolve(homedir(), ".local", "share"); - return resolve(dataHome, "opencode", "auth.json"); -} - function getPluginConfigPath() { const xdgConfigHome = String(process.env.XDG_CONFIG_HOME ?? "").trim(); const configHome = xdgConfigHome || resolve(homedir(), ".config"); @@ -246,28 +253,141 @@ function resolveBehaviorFromSources(settings) { function hasAuthStoreKey() { const authPath = getAuthStorePath(); - if (!existsSync(authPath)) { - return { hasKey: false, path: authPath }; - } + const entry = hasAuthEntrySync(authPath); + return { hasKey: Boolean(entry), path: authPath, providerID: entry?.providerID ?? null }; +} +function hasAuthEntrySync(authPath) { try { - const parsed = JSON.parse(readFileSync(authPath, "utf8")); - const auth = parsed?.[AUTH_PROVIDER_ID]; - const hasKey = Boolean(auth && auth.type === "api" && String(auth.key ?? "").trim()); - return { hasKey, path: authPath }; + const raw = readFileSync(authPath, "utf8"); + const trimmed = String(raw ?? "").trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + for (const candidateID of [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS]) { + const auth = parsed[candidateID]; + if (auth && auth.type === "api" && String(auth.key ?? "").trim()) { + return { providerID: candidateID, key: String(auth.key).trim() }; + } + } + return null; } catch { - return { hasKey: false, path: authPath }; + return null; } } function printUsage() { - console.log("Usage: opencode-ttc-plugin "); + console.log("Usage: opencode-ttc-plugin "); console.log(" opencode-ttc-plugin doctor [--verbose]"); console.log(" opencode-ttc-plugin config get"); console.log(" opencode-ttc-plugin config set level "); console.log(" opencode-ttc-plugin config set aggressiveness <0..1>"); console.log(" opencode-ttc-plugin config set "); console.log(" opencode-ttc-plugin config reset"); + console.log(" opencode-ttc-plugin login [--stdin] (key from stdin, no echo)"); + console.log(" opencode-ttc-plugin logout"); +} + +function promptSecret(query) { + return new Promise((onResolve) => { + const input = process.stdin; + const realOutput = process.stdout; + if (!input.isTTY) { + onResolve(""); + return; + } + realOutput.write(query); + const mutedOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } + }); + const rl = readline.createInterface({ + input, + output: mutedOutput, + terminal: true, + prompt: "" + }); + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + try { rl.close(); } catch { /* ignore */ } + realOutput.write("\n"); + onResolve(value); + }; + rl.question("", (answer) => { + finish(String(answer ?? "").replace(/\r?\n$/, "")); + }); + rl.on("SIGINT", () => finish("")); + }); +} + +async function readStdinSecret() { + let data = ""; + for await (const chunk of process.stdin) { + data += String(chunk); + } + return data.replace(/\r?\n$/, "").trim(); +} + +async function persistKey(apiKey) { + if (!apiKey) { + console.error("Login cancelled: no key entered."); + process.exitCode = 1; + return; + } + const result = await writeAuthEntry({ apiKey }); + if (!result.ok) { + console.error(`Login failed: ${result.reason}${result.error ? ` (${result.error.message})` : ""}`); + if (result.reason === "auth_store_corrupt") { + console.error(`Refusing to overwrite corrupt auth store at ${result.authFilePath}. Back it up and remove it manually.`); + } + process.exitCode = 1; + return; + } + console.log(`Saved TTC API key under '${result.providerID}' at ${result.authFilePath}`); + if (result.removedLegacyIDs?.length) { + console.log(`Removed stale legacy entries: ${result.removedLegacyIDs.join(", ")}`); + } + console.log("Restart opencode for sessions to pick up the new key."); +} + +async function loginCommand(args) { + const useStdin = args.includes("--stdin"); + if (useStdin) { + if (process.stdin.isTTY) { + console.error('Login --stdin requires the key to be piped (e.g. printf %s "$KEY" | opencode-ttc-plugin login --stdin).'); + process.exitCode = 1; + return; + } + const apiKey = await readStdinSecret(); + await persistKey(apiKey); + return; + } + + if (process.stdin.isTTY) { + const apiKey = await promptSecret("Enter TTC API key (from thetokencompany.com): "); + await persistKey(apiKey); + return; + } + + console.error('Login requires --stdin when stdin is not a TTY (e.g. printf %s "$KEY" | opencode-ttc-plugin login --stdin).'); + process.exitCode = 1; +} + +async function logoutCommand() { + const result = await removeAuthEntry(); + if (!result.ok) { + console.error(`Logout failed: ${result.reason}${result.error ? ` (${result.error.message})` : ""}`); + process.exitCode = 1; + return; + } + if (!result.removedAny) { + console.log(`No TTC auth entry found at ${result.authFilePath}`); + return; + } + console.log(`Removed TTC auth entries at ${result.authFilePath}`); } function detectCommand(argv, scriptName) { @@ -316,7 +436,14 @@ function doctor(options = { verbose: false }) { { label: `auth store (${AUTH_PROVIDER_ID})`, ok: true, - value: authStore.hasKey ? `set (${authStore.path})` : `missing (${authStore.path})` + value: authStore.hasKey + ? `set under '${authStore.providerID}' (${authStore.path})` + : `missing (${authStore.path})` + }, + { + label: "known TTC models", + ok: true, + value: KNOWN_MODELS.map((model) => model.id).join(", ") }, { label: "effective auth source", ok: hasUsableAuth, value: authSource }, { @@ -496,7 +623,7 @@ function uninstall() { console.log(`Removed ${installedPluginPath}`); } -function main() { +async function main() { const scriptName = basename(process.argv[1] ?? ""); const command = detectCommand(process.argv, scriptName); const args = process.argv.slice(3); @@ -521,6 +648,16 @@ function main() { return; } + if (command === "login") { + await loginCommand(args); + return; + } + + if (command === "logout") { + await logoutCommand(); + return; + } + printUsage(); process.exitCode = 1; } diff --git a/tests/opencode-ttc-plugin.test.js b/tests/opencode-ttc-plugin.test.js index 3b8189b..fb66145 100644 --- a/tests/opencode-ttc-plugin.test.js +++ b/tests/opencode-ttc-plugin.test.js @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { join, dirname } from "node:path"; import TtcMessageTransformPlugin from "../opencode-plugins/ttc-message-transform.js"; import { @@ -11,6 +11,7 @@ import { createSessionStats, getPluginConfigPath, getSidebarStatePath, + hydrateSessionStatsFromSidebarState, getAuthStorePath, getSkipReasonForText, recordProcessedPart, @@ -31,15 +32,24 @@ import { formatMetricValue, formatPartLine, getStatusDotColor, + emptySidebarStateText, + loadAuthStatus, loadSidebarState, + shouldRenderSidebarState, statusText } from "../tui/sidebar-state.js"; import { getTtcSettingsConfigPath, + hasTtcAuthKey, + openTtcSettingsMenu, registerTtcSettingsCommand, resetTtcSettings, - updateTtcSetting + updateTtcSetting, + removeAuthEntry as removeTuiAuthEntry, + AUTH_PROVIDER_ID as TUI_AUTH_PROVIDER_ID, + KNOWN_MODELS as TUI_KNOWN_MODELS } from "../tui/settings.js"; +import { writeAuthEntry as writeTuiAuthEntry } from "../lib/auth-store.js"; function createOutput(text) { return { @@ -527,6 +537,15 @@ test("resolves auth store path from XDG_DATA_HOME", () => { assert.equal(path, "/tmp/xdg-data/opencode/auth.json"); }); +test("auth store path falls back to $HOME when XDG_DATA_HOME is relative", () => { + const expected = join(homedir(), ".local", "share", "opencode", "auth.json"); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "relative/data" }), expected); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "./data" }), expected); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "data" }), expected); + assert.equal(getAuthStorePath({ XDG_DATA_HOME: "" }), expected); + assert.equal(getAuthStorePath({}), expected); +}); + test("resolves api key from OpenCode auth store for provider id", async () => { const key = await resolveApiKeyFromAuthStore({ readFileImpl: async () => JSON.stringify({ @@ -649,6 +668,150 @@ test("writes and loads sidebar state by hashed session path", async () => { } }); +test("hydrates session stats from persisted sidebar state before writing new resume metrics", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-resume-state-")); + + try { + const priorStats = createSessionStats(); + recordProcessedPart(priorStats, { + charsBefore: 1000, + charsAfter: 400, + compressed: true, + fallback: false, + cacheHit: false, + tokenSavingsExact: 150 + }); + + const statePath = getSidebarStatePath("sess-1", { XDG_STATE_HOME: tempDir }); + await writeSidebarState({ + stats: priorStats, + config: baseConfig, + sessionID: "sess-1", + statePath + }); + + const persisted = await loadSidebarState("sess-1", { statePath }); + const hydrated = hydrateSessionStatsFromSidebarState(persisted); + assert.equal(hydrated.processed, 1); + assert.equal(hydrated.charsBefore, 1000); + assert.equal(hydrated.charsAfter, 400); + assert.equal(hydrated.exactTokensSaved, 150); + + const output = createOutput("this resumed session prompt should add to the existing persisted metrics"); + await transformMessagesWithTtc({ + output, + client: createClient(), + config: baseConfig, + cache: new Map(), + sessionStats: new Map(), + env: { XDG_STATE_HOME: tempDir }, + fetchImpl: async () => ({ + ok: true, + async json() { + return { + output: "compressed after resume", + input_tokens: 200, + output_tokens: 80 + }; + } + }) + }); + + const updated = await loadSidebarState("sess-1", { statePath }); + assert.equal(updated.session.processed, 2); + assert.equal(updated.session.compressed, 2); + assert.equal(updated.session.charsBefore, 1000 + "this resumed session prompt should add to the existing persisted metrics".length); + assert.equal(updated.session.charsAfter, 400 + "compressed after resume".length); + assert.equal(updated.session.exactTokensSaved, 270); + assert.equal(updated.lastMessage.charsAfter, "compressed after resume".length); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("hydrating sidebar state ignores non-finite numeric counters", () => { + const hydrated = hydrateSessionStatsFromSidebarState({ + schemaVersion: 1, + session: { + processed: Infinity, + compressed: "Infinity", + skipped: -2, + charsBefore: "120" + }, + lastMessage: { + charsBefore: Infinity, + tokensSaved: "NaN", + partsProcessed: 3 + } + }); + + assert.equal(hydrated.processed, 0); + assert.equal(hydrated.compressed, 0); + assert.equal(hydrated.skipped, 0); + assert.equal(hydrated.charsBefore, 120); + assert.equal(hydrated.lastMessageCharsBefore, 0); + assert.equal(hydrated.lastMessageTokensSaved, 0); + assert.equal(hydrated.lastMessagePartsProcessed, 3); +}); + +test("concurrent first-use hydration shares one persisted session stats object", async () => { + const priorStats = createSessionStats(); + recordProcessedPart(priorStats, { + charsBefore: 1000, + charsAfter: 400, + compressed: true, + fallback: false, + cacheHit: false, + tokenSavingsExact: 150 + }); + const persistedState = JSON.stringify(buildSidebarState({ + stats: priorStats, + config: baseConfig, + sessionID: "sess-1" + })); + const sessionStats = new Map(); + const writes = []; + let readCalls = 0; + let releaseHydration; + const hydrationStarted = new Promise((resolve) => { + releaseHydration = resolve; + }); + + const makeTransform = (text, compressedText) => transformMessagesWithTtc({ + output: createOutput(text), + client: createClient(), + config: baseConfig, + cache: new Map(), + sessionStats, + readSidebarStateImpl: async () => { + readCalls += 1; + await hydrationStarted; + return persistedState; + }, + writeSidebarStateImpl: async ({ stats, config, sessionID, authSource }) => { + writes.push(buildSidebarState({ stats, config, sessionID, authSource })); + }, + fetchImpl: async () => ({ + ok: true, + async json() { + return { + output: compressedText + }; + } + }) + }); + + const first = makeTransform("first resumed prompt should keep prior cumulative metrics", "first compressed"); + const second = makeTransform("second resumed prompt should share hydrated metrics too", "second compressed"); + releaseHydration(); + await Promise.all([first, second]); + + assert.equal(readCalls, 1); + assert.equal(writes.at(-1).session.processed, 3); + assert.equal(writes.at(-1).session.compressed, 3); + assert.equal(sessionStats.get("sess-1").processed, 3); +}); + test("loadSidebarState returns null for malformed or missing state", async () => { const missing = await loadSidebarState("missing-session", { readFileImpl: async () => { @@ -663,6 +826,17 @@ test("loadSidebarState returns null for malformed or missing state", async () => assert.equal(malformed, null); }); +test("sidebar session switch helpers avoid rendering stale state as zeroed metrics", () => { + const currentState = { session: { processed: 2 } }; + + assert.equal(shouldRenderSidebarState(currentState, "session-a", "session-a"), true); + assert.equal(shouldRenderSidebarState(currentState, "session-a", "session-b"), false); + assert.equal(shouldRenderSidebarState(null, "session-a", "session-a"), false); + assert.equal(emptySidebarStateText({ loading: true, messageCount: 0 }), "loading session metrics"); + assert.equal(emptySidebarStateText({ loading: false, messageCount: 0 }), "new session"); + assert.equal(emptySidebarStateText({ loading: false, messageCount: 3 }), "waiting for first compression"); +}); + test("transform writes sidebar state after processing a session message", async () => { let capturedState = null; const sessionStats = new Map(); @@ -788,12 +962,34 @@ test("registers plugin auth provider for /connect flow", async () => { const client = createClient(); const plugin = await TtcMessageTransformPlugin({ client }); - assert.equal(plugin.auth.provider, "the-token-company-plugin"); + assert.equal(plugin.auth.provider, "opencode-ttc-plugin"); assert.equal(Array.isArray(plugin.auth.methods), true); assert.equal(plugin.auth.methods.length > 0, true); assert.equal(plugin.auth.methods[0].type, "api"); }); +test("reads legacy auth store entries written under the old provider id", async () => { + const key = await resolveApiKeyFromAuthStore({ + readFileImpl: async () => JSON.stringify({ + "the-token-company-plugin": { + type: "api", + key: "legacy_key" + } + }) + }); + assert.equal(key, "legacy_key"); +}); + +test("prefers new provider id when both legacy and current entries exist", async () => { + const key = await resolveApiKeyFromAuthStore({ + readFileImpl: async () => JSON.stringify({ + "the-token-company-plugin": { type: "api", key: "legacy_key" }, + "opencode-ttc-plugin": { type: "api", key: "current_key" } + }) + }); + assert.equal(key, "current_key"); +}); + test("shows activation and idle summary toasts in TUI", async () => { const originalFetch = globalThis.fetch; const tempStateHome = await mkdtemp(join(tmpdir(), "ttc-plugin-state-")); @@ -886,6 +1082,123 @@ test("registers TUI settings command and slash aliases", () => { assert.equal(typeof command.onSelect, "function"); }); +test("registers /ttc-login and /ttc-logout slash commands", () => { + let registeredCallback = null; + registerTtcSettingsCommand({ + command: { + register(callback) { + registeredCallback = callback; + return () => {}; + } + }, + ui: { dialog: {}, toast() {} } + }); + + const commands = registeredCallback(); + const login = commands.find((cmd) => cmd.value === "ttc.login"); + const logout = commands.find((cmd) => cmd.value === "ttc.logout"); + + assert.equal(Boolean(login), true); + assert.equal(login.slash.name, "ttc-login"); + assert.equal(typeof login.onSelect, "function"); + assert.equal(Boolean(logout), true); + assert.equal(logout.slash.name, "ttc-logout"); + assert.equal(typeof logout.onSelect, "function"); +}); + +test("/ttc-login never opens a visible secret prompt", () => { + let registeredCallback = null; + let alertInput = null; + let promptOpened = false; + const api = { + command: { + register(callback) { + registeredCallback = callback; + return () => {}; + } + }, + ui: { + DialogAlert(input) { + alertInput = input; + return input; + }, + DialogPrompt() { + promptOpened = true; + return {}; + }, + dialog: { + replace(render) { render(); } + }, + toast() {} + } + }; + + registerTtcSettingsCommand(api); + const login = registeredCallback().find((cmd) => cmd.value === "ttc.login"); + login.onSelect(api.ui.dialog); + + assert.equal(promptOpened, false); + assert.equal(alertInput.title, "Add TTC API Key"); + assert.equal(alertInput.message.includes("opencode auth login"), true); + assert.equal(alertInput.message.includes("opencode-ttc-plugin login"), true); +}); + +test("settings menu shows one compact auth row based on persisted auth state", async () => { + const tempDataHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-data-")); + const tempConfigHome = await mkdtemp(join(tmpdir(), "ttc-menu-auth-config-")); + const originalEnv = { + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + TTC_API_KEY: process.env.TTC_API_KEY + }; + process.env.XDG_DATA_HOME = tempDataHome; + process.env.XDG_CONFIG_HOME = tempConfigHome; + process.env.TTC_API_KEY = "ttc_env_key"; + + let latestSelect = null; + const api = { + ui: { + DialogSelect(input) { + latestSelect = input; + return input; + }, + dialog: { + setSize() {}, + replace(render) { render(); } + }, + toast() {} + } + }; + + try { + await openTtcSettingsMenu(api, api.ui.dialog); + let authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); + assert.equal(authRows.length, 1); + assert.equal(authRows[0].value, "ttc-login"); + assert.equal(authRows[0].title, "Add API key"); + + await writeTuiAuthEntry({ apiKey: "ttc_existing_key" }); + await openTtcSettingsMenu(api, api.ui.dialog); + authRows = latestSelect.options.filter((option) => option.value === "ttc-login" || option.value === "ttc-logout"); + assert.equal(authRows.length, 1); + assert.equal(authRows[0].value, "ttc-logout"); + assert.equal(authRows[0].title, "Remove API key"); + assert.equal(authRows[0].description, "Revoke saved key"); + } finally { + if (originalEnv.XDG_DATA_HOME === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = originalEnv.XDG_DATA_HOME; + + if (originalEnv.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalEnv.XDG_CONFIG_HOME; + + if (originalEnv.TTC_API_KEY === undefined) delete process.env.TTC_API_KEY; + else process.env.TTC_API_KEY = originalEnv.TTC_API_KEY; + + await rm(tempDataHome, { recursive: true, force: true }); + await rm(tempConfigHome, { recursive: true, force: true }); + } +}); + test("TUI settings path follows existing XDG config resolution", () => { const path = getTtcSettingsConfigPath({ XDG_CONFIG_HOME: "/tmp/xdg-config" }); assert.equal(path, "/tmp/xdg-config/opencode/ttc-plugin.json"); @@ -951,3 +1264,233 @@ test("TUI settings helper rejects invalid values and resets config", async () => await rm(tempDir, { recursive: true, force: true }); } }); + +test("TUI auth helpers write/remove key under current provider id with secure file mode", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-")); + const authPath = join(tempDir, "opencode", "auth.json"); + + try { + const written = await writeTuiAuthEntry({ + apiKey: "ttc_example_key", + authFilePath: authPath + }); + assert.equal(written.ok, true); + assert.equal(written.providerID, "opencode-ttc-plugin"); + + const stored = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(stored["opencode-ttc-plugin"].type, "api"); + assert.equal(stored["opencode-ttc-plugin"].key, "ttc_example_key"); + + const { statSync } = await import("node:fs"); + const mode = statSync(authPath).mode & 0o777; + assert.equal(mode, 0o600, `auth file should be 0o600, got 0o${mode.toString(8)}`); + const dirMode = statSync(dirname(authPath)).mode & 0o777; + assert.equal(dirMode, 0o700, `auth directory should be 0o700, got 0o${dirMode.toString(8)}`); + + const hasAfter = await hasTtcAuthKey({ authFilePath: authPath }); + assert.equal(hasAfter.hasKey, true); + assert.equal(hasAfter.providerID, "opencode-ttc-plugin"); + + const removed = await removeTuiAuthEntry({ authFilePath: authPath }); + assert.equal(removed.ok, true); + assert.equal(removed.removedAny, true); + + const hasAfterRemove = await hasTtcAuthKey({ authFilePath: authPath }); + assert.equal(hasAfterRemove.hasKey, false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("logout deletes targeted provider entries even when stored value is falsy", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-falsy-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile( + authPath, + JSON.stringify({ + "opencode-ttc-plugin": null, + "the-token-company-plugin": "", + anthropic: { type: "api", key: "sk-ant-keep" } + }, null, 2), + "utf8" + ); + + try { + const removed = await removeTuiAuthEntry({ authFilePath: authPath }); + assert.equal(removed.ok, true); + assert.equal(removed.removedAny, true); + + const stored = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(Object.prototype.hasOwnProperty.call(stored, "opencode-ttc-plugin"), false); + assert.equal(Object.prototype.hasOwnProperty.call(stored, "the-token-company-plugin"), false); + assert.equal(stored.anthropic.key, "sk-ant-keep"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("auth store refuses to overwrite a corrupt file", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-corrupt-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile(authPath, "{ not valid json,,,", "utf8"); + + try { + const result = await writeTuiAuthEntry({ + apiKey: "ttc_new_key", + authFilePath: authPath + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "auth_store_corrupt"); + + const preserved = await readFile(authPath, "utf8"); + assert.equal(preserved, "{ not valid json,,,"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("auth store write preserves unrelated providers during sequential key rotation", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-race-")); + const authPath = join(tempDir, "opencode", "auth.json"); + + try { + await writeTuiAuthEntry({ + apiKey: "ttc_first_key", + authFilePath: authPath + }); + const intermediate = JSON.parse(await readFile(authPath, "utf8")); + intermediate["another-provider"] = { type: "api", key: "other_key" }; + const { writeFile: realWriteFile, rename: realRename } = await import("node:fs/promises"); + await realWriteFile(authPath, JSON.stringify(intermediate, null, 2), "utf8"); + + const result = await writeTuiAuthEntry({ + apiKey: "ttc_second_key", + authFilePath: authPath + }); + assert.equal(result.ok, true); + + const final = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(final["opencode-ttc-plugin"].key, "ttc_second_key"); + assert.equal(final["another-provider"].key, "other_key"); + void realRename; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("logout refuses unsupported auth-store paths instead of claiming success", async () => { + const result = await removeTuiAuthEntry({ + authFilePath: "/tmp/opencode-auth-symlink.json", + lstatImpl: async () => ({ isFile: () => false }) + }); + + assert.equal(result.ok, false); + assert.equal(result.reason, "auth_store_not_regular_file"); + assert.equal(result.removedAny, undefined); +}); + +test("login rotates away stale legacy TTC entries and leaves unrelated providers intact", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-rotate-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile( + authPath, + JSON.stringify({ + "the-token-company-plugin": { type: "api", key: "ttc_old_legacy" }, + "opencode-ttc-plugin": { type: "api", key: "ttc_old_current" }, + "anthropic": { type: "api", key: "sk-ant-keepMe" }, + "openai": { type: "api", key: "sk-oai-keepMe" } + }, null, 2), + "utf8" + ); + + try { + const result = await writeTuiAuthEntry({ + apiKey: "ttc_rotated", + authFilePath: authPath + }); + assert.equal(result.ok, true); + assert.deepEqual(result.removedLegacyIDs, ["the-token-company-plugin"]); + + const final = JSON.parse(await readFile(authPath, "utf8")); + assert.equal(final["opencode-ttc-plugin"].key, "ttc_rotated"); + assert.equal(final["the-token-company-plugin"], undefined, "legacy id should be cleared on rotation"); + assert.equal(final["anthropic"].key, "sk-ant-keepMe", "unrelated providers must be preserved"); + assert.equal(final["openai"].key, "sk-oai-keepMe", "unrelated providers must be preserved"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("login with no legacy entry present reports empty removedLegacyIDs", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-auth-fresh-")); + const authPath = join(tempDir, "opencode", "auth.json"); + + try { + const result = await writeTuiAuthEntry({ + apiKey: "ttc_brand_new", + authFilePath: authPath + }); + assert.equal(result.ok, true); + assert.deepEqual(result.removedLegacyIDs, []); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("loadAuthStatus recognises legacy provider entries", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-tui-authstatus-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + await writeFile( + authPath, + JSON.stringify({ "the-token-company-plugin": { type: "api", key: "legacy" } }), + "utf8" + ); + + try { + const status = await loadAuthStatus({ authFilePath: authPath }); + assert.equal(status.hasKey, true); + assert.equal(status.providerID, "the-token-company-plugin"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("loadAuthStatus and hasAuthEntry treat TTC_API_KEY env as valid auth source", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ttc-env-auth-")); + const authPath = join(tempDir, "opencode", "auth.json"); + await mkdir(dirname(authPath), { recursive: true }); + // no key file present -> store reports no key + + const envWithKey = { TTC_API_KEY: "ttc_env_key_123" }; + const envNoKey = {}; + + try { + // env short-circuits regardless of store + const statusEnv = await loadAuthStatus({ env: envWithKey, authFilePath: authPath }); + assert.equal(statusEnv.hasKey, true); + assert.equal(statusEnv.providerID, "env"); + + const statusNoEnv = await loadAuthStatus({ env: envNoKey, authFilePath: authPath }); + assert.equal(statusNoEnv.hasKey, false); + + // hasAuthEntry (TUI hasTtcAuthKey) also respects env + const hasEnv = await hasTtcAuthKey({ env: envWithKey, authFilePath: authPath }); + assert.equal(hasEnv.hasKey, true); + assert.equal(hasEnv.providerID, "env"); + + const hasNoEnv = await hasTtcAuthKey({ env: envNoKey, authFilePath: authPath }); + assert.equal(hasNoEnv.hasKey, false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("known TTC model list is grounded in current docs", () => { + const ids = TUI_KNOWN_MODELS.map((model) => model.id); + assert.deepEqual(ids.sort(), ["bear-1.2", "bear-2"].sort()); + assert.equal(TUI_AUTH_PROVIDER_ID, "opencode-ttc-plugin"); +}); diff --git a/tui/index.tsx b/tui/index.tsx index 78728fc..70c3511 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -2,15 +2,19 @@ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; import type { TuiPlugin, TuiPluginApi, TuiTheme } from "@opencode-ai/plugin/tui"; import { + emptySidebarStateText, formatMetricValue, formatPartLine, getStatusDotColor, + loadAuthStatus, loadSidebarState, + shouldRenderSidebarState, statusText } from "./sidebar-state.js"; import { registerTtcSettingsCommand } from "./settings.js"; type SidebarState = Awaited>; +type AuthStatus = Awaited>; function SidebarContent(props: { api: TuiPluginApi; @@ -18,22 +22,30 @@ function SidebarContent(props: { theme: TuiTheme; }) { const [state, setState] = createSignal(null); + const [loadedSessionID, setLoadedSessionID] = createSignal(""); + const [loadingSessionID, setLoadingSessionID] = createSignal(""); + const [authStatus, setAuthStatus] = createSignal({ hasKey: false, providerID: null, authPath: "" }); const [refreshCount, setRefreshCount] = createSignal(0); const theme = () => props.theme.current; - const refresh = async () => { - setState(await loadSidebarState(props.sessionID)); - }; + const refresh = async (sessionID = props.sessionID) => { + setLoadingSessionID(sessionID); + const [nextState, nextAuthStatus] = await Promise.all([ + loadSidebarState(sessionID), + loadAuthStatus() + ]); - createEffect(() => { - props.sessionID; - setState(null); - void refresh(); - }); + if (props.sessionID !== sessionID) return; + setState(nextState); + setLoadedSessionID(sessionID); + setAuthStatus(nextAuthStatus); + setLoadingSessionID(""); + }; createEffect(() => { + const sessionID = props.sessionID; refreshCount(); - void refresh(); + void refresh(sessionID); }); const triggerRefresh = (event: { properties?: Record }) => { @@ -52,9 +64,49 @@ function SidebarContent(props: { unsubscribeSessionStatus(); }); + const sessionMessageCount = createMemo(() => props.api.state.session.messages(props.sessionID)?.length ?? 0); + + const hasAuth = createMemo(() => authStatus().hasKey); + + const visibleState = createMemo(() => { + if (!hasAuth()) { + return null; // hide any stale compressed/skipped state once logged out + } + return shouldRenderSidebarState(state(), loadedSessionID(), props.sessionID) ? state() : null; + }); + + const isLoadingCurrentSession = createMemo(() => loadingSessionID() === props.sessionID && !visibleState()); + + const effectiveStatus = createMemo(() => { + if (!hasAuth()) { + return "missing_auth"; + } + const current = visibleState(); + if (current?.status === "missing_auth" && hasAuth()) { + return "waiting"; + } + return current?.status; + }); + const dotColor = createMemo(() => { - const current = state(); - return getStatusDotColor(current?.status, theme()); + return getStatusDotColor(effectiveStatus(), theme()); + }); + + const statusLine = createMemo(() => { + const current = visibleState(); + if (!current) { + if (!hasAuth()) { + return "missing TTC auth"; + } + return emptySidebarStateText({ + loading: isLoadingCurrentSession(), + messageCount: sessionMessageCount() + }); + } + if (effectiveStatus() === "waiting") { + return "authenticated — send a message to start compressing"; + } + return statusText(current); }); return ( @@ -62,10 +114,10 @@ function SidebarContent(props: { Token Compression - {state()?.config?.model ?? ""} + {visibleState()?.config?.model ?? ""} - {statusText(state())} - No session data yet}> + {statusLine()} + No metrics for this session yet}> {(current) => ( diff --git a/tui/settings.js b/tui/settings.js index 298d075..2da556d 100644 --- a/tui/settings.js +++ b/tui/settings.js @@ -1,9 +1,21 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { + AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, + getAuthStorePath, + hasAuthEntry, + writeAuthEntry, + removeAuthEntry +} from "../lib/auth-store.js"; export const TTC_SETTINGS_COMMAND_VALUE = "ttc.settings"; export const TTC_SETTINGS_COMMAND_TITLE = "Token Compression: Settings"; +export const TTC_LOGIN_COMMAND_VALUE = "ttc.login"; +export const TTC_LOGIN_COMMAND_TITLE = "Token Compression: Login"; +export const TTC_LOGOUT_COMMAND_VALUE = "ttc.logout"; +export const TTC_LOGOUT_COMMAND_TITLE = "Token Compression: Logout"; export const COMPRESSION_LEVELS = { low: 0.05, @@ -12,6 +24,13 @@ export const COMPRESSION_LEVELS = { max: 0.3 }; +export { AUTH_PROVIDER_ID, LEGACY_AUTH_PROVIDER_IDS }; + +export const KNOWN_MODELS = [ + { id: "bear-2", label: "bear-2 (Recommended)", description: "Most accurate compression. Best quality preservation." }, + { id: "bear-1.2", label: "bear-1.2", description: "Faster compression. Lower latency per request." } +]; + const DEFAULT_SETTINGS = { enabled: true, compressionLevel: "balanced", @@ -30,6 +49,13 @@ export function getTtcSettingsConfigPath(env = process.env) { return join(configHome, "opencode", "ttc-plugin.json"); } +export { + getAuthStorePath, + hasAuthEntry as hasTtcAuthKey, + writeAuthEntry, + removeAuthEntry +}; + export async function readTtcSettings({ configPath = getTtcSettingsConfigPath(), readFileImpl = readFile @@ -197,6 +223,85 @@ function selectLevel(api, dialog, current) { })); } +function selectModel(api, dialog, currentValue) { + const knownIDs = KNOWN_MODELS.map((model) => model.id); + const options = KNOWN_MODELS.map((model) => ({ + title: model.label, + value: model.id, + description: model.description + })); + const isCustom = currentValue && !knownIDs.includes(currentValue); + if (isCustom) { + options.push({ + title: `Custom: ${currentValue}`, + value: currentValue, + description: "Currently set to a non-standard model id" + }); + } + options.push({ + title: "Enter custom model id", + value: "__custom__", + description: "Specify a model id (e.g. an enterprise fine-tune)" + }); + + dialog.replace(() => api.ui.DialogSelect({ + title: "Compression Model", + current: currentValue, + options, + onSelect: (option) => { + if (option.value === "__custom__") { + promptValue(api, dialog, { + title: "Custom Model", + placeholder: "bear-1.2", + value: currentValue, + action: "set-model" + }); + return; + } + void saveAndReturn(api, dialog, "set-model", option.value); + } + })); +} + +function promptLogin(api, dialog) { + dialog.replace(() => api.ui.DialogAlert({ + title: "Add TTC API Key", + message: "Use `opencode auth login` and choose opencode-ttc-plugin, or run `opencode-ttc-plugin login` in a terminal. The TUI does not accept API keys because plain dialogs can display secrets.", + onConfirm: () => openTtcSettingsMenu(api, dialog) + })); +} + +const LOGOUT_FAILURE_MESSAGES = { + auth_store_corrupt: "opencode auth store is corrupt — refusing to overwrite. Back up and remove the file manually.", + auth_store_not_regular_file: "opencode auth store path is not a regular file.", + auth_store_read_failed: "Could not read the opencode auth store.", + auth_store_write_failed: "Could not write the opencode auth store." +}; +async function confirmLogout(api, dialog) { + dialog.replace(() => api.ui.DialogConfirm({ + title: "Remove TTC API Key", + message: "Remove the TTC API key from the opencode auth store?", + onConfirm: async () => { + const result = await removeAuthEntry(); + if (!result.ok) { + const message = LOGOUT_FAILURE_MESSAGES[result.reason] ?? `Logout failed: ${result.reason}.`; + toast(api, { variant: "error", message, duration: 5000 }); + renderAlert(api, dialog, "Logout Failed", message); + return; + } + toast(api, { + variant: result.removedAny ? "success" : "info", + message: result.removedAny + ? "TTC API key removed. Restart opencode to fully clear active sessions." + : "No TTC auth entry was present.", + duration: 4500 + }); + await openTtcSettingsMenu(api, dialog); + }, + onCancel: () => openTtcSettingsMenu(api, dialog) + })); +} + function confirmReset(api, dialog) { dialog.replace(() => api.ui.DialogConfirm({ title: "Reset Token Compression Settings", @@ -218,42 +323,57 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { if (!api?.ui || !dialog?.replace) return; const settings = await readTtcSettings(); + const storeAuth = await hasAuthEntry({ env: {} }); const view = buildSettingsView(settings); + const authOption = storeAuth.hasKey + ? { + title: "Remove API key", + value: "ttc-logout", + description: "Revoke saved key" + } + : { + title: "Add API key", + value: "ttc-login", + description: "Required for compression" + }; + const options = [ + { + title: `${view.enabled ? "Disable" : "Enable"} compression`, + value: "toggle-enabled", + description: `Currently ${view.enabled ? "enabled" : "disabled"}` + }, + { + title: "Compression level", + value: "set-level", + description: view.compressionLevel === "custom" ? "Custom aggressiveness" : view.compressionLevel + }, + { + title: "Custom aggressiveness", + value: "set-aggressiveness", + description: String(view.aggressiveness) + }, + { + title: "Min chars", + value: "set-min-chars", + description: String(view.minChars) + }, + { + title: "Model", + value: "set-model", + description: view.model + }, + authOption, + { + title: "Reset config", + value: "reset-config", + description: "Remove plugin config file" + } + ]; + dialog.setSize?.("medium"); dialog.replace(() => api.ui.DialogSelect({ title: "Token Compression Settings", - options: [ - { - title: `${view.enabled ? "Disable" : "Enable"} compression`, - value: "toggle-enabled", - description: `Currently ${view.enabled ? "enabled" : "disabled"}` - }, - { - title: "Compression level", - value: "set-level", - description: view.compressionLevel === "custom" ? "Custom aggressiveness" : view.compressionLevel - }, - { - title: "Custom aggressiveness", - value: "set-aggressiveness", - description: String(view.aggressiveness) - }, - { - title: "Min chars", - value: "set-min-chars", - description: String(view.minChars) - }, - { - title: "Model", - value: "set-model", - description: view.model - }, - { - title: "Reset config", - value: "reset-config", - description: "Remove plugin config file" - } - ], + options, onSelect: (option) => { if (option.value === "toggle-enabled") { void saveAndReturn(api, dialog, "toggle-enabled"); @@ -282,12 +402,15 @@ export async function openTtcSettingsMenu(api, dialog = api.ui?.dialog) { return; } if (option.value === "set-model") { - promptValue(api, dialog, { - title: "Model", - placeholder: "bear-1.2", - value: view.model, - action: "set-model" - }); + selectModel(api, dialog, view.model); + return; + } + if (option.value === "ttc-login") { + promptLogin(api, dialog); + return; + } + if (option.value === "ttc-logout") { + confirmLogout(api, dialog); return; } if (option.value === "reset-config") { @@ -310,6 +433,26 @@ export function registerTtcSettingsCommand(api) { aliases: ["ttc"] }, onSelect: (dialog) => void openTtcSettingsMenu(api, dialog ?? api.ui?.dialog) + }, + { + title: TTC_LOGIN_COMMAND_TITLE, + value: TTC_LOGIN_COMMAND_VALUE, + description: "Set your TTC API key in the opencode auth store", + category: "Token Compression", + slash: { + name: "ttc-login" + }, + onSelect: (dialog) => void promptLogin(api, dialog ?? api.ui?.dialog) + }, + { + title: TTC_LOGOUT_COMMAND_TITLE, + value: TTC_LOGOUT_COMMAND_VALUE, + description: "Remove the TTC API key from the opencode auth store", + category: "Token Compression", + slash: { + name: "ttc-logout" + }, + onSelect: (dialog) => void confirmLogout(api, dialog ?? api.ui?.dialog) } ]); } diff --git a/tui/sidebar-state.js b/tui/sidebar-state.js index 3404931..28b00b4 100644 --- a/tui/sidebar-state.js +++ b/tui/sidebar-state.js @@ -2,6 +2,14 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { + AUTH_PROVIDER_ID, + LEGACY_AUTH_PROVIDER_IDS, + getAuthStorePath as getSharedAuthStorePath +} from "../lib/auth-store.js"; + +export { AUTH_PROVIDER_ID }; +export const SHARED_AUTH_PROVIDER_IDS = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS]; const SKIP_REASON_LABELS = { below_threshold: "below threshold", @@ -26,6 +34,39 @@ export function getSidebarStatePath(sessionID, env = process.env) { return join(getSidebarStateDir(env), `${sessionHash}.json`); } +export function getAuthStorePath(env = process.env) { + return getSharedAuthStorePath(env); +} + +export async function loadAuthStatus(options = {}) { + const env = options.env ?? process.env; + const envKey = String(env.TTC_API_KEY ?? "").trim(); + if (envKey) { + return { hasKey: true, providerID: "env", authPath: "" }; + } + + const readFileImpl = options.readFileImpl ?? readFile; + const authPath = options.authFilePath ?? getSharedAuthStorePath(env); + try { + const content = await readFileImpl(authPath, "utf8"); + const trimmed = String(content ?? "").trim(); + if (!trimmed) return { hasKey: false, providerID: null, authPath }; + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { hasKey: false, providerID: null, authPath }; + } + for (const candidateID of SHARED_AUTH_PROVIDER_IDS) { + const auth = parsed[candidateID]; + if (auth && auth.type === "api" && String(auth.key ?? "").trim()) { + return { hasKey: true, providerID: candidateID, authPath }; + } + } + return { hasKey: false, providerID: null, authPath }; + } catch { + return { hasKey: false, providerID: null, authPath }; + } +} + export async function loadSidebarState(sessionID, options = {}) { const readFileImpl = options.readFileImpl ?? readFile; const statePath = options.statePath ?? getSidebarStatePath(sessionID, options.env ?? process.env); @@ -86,6 +127,15 @@ export function formatPartLine(session = {}) { return `${compressed}/${processed} parts compressed`; } +export function shouldRenderSidebarState(state, loadedSessionID, currentSessionID) { + return Boolean(state && loadedSessionID && loadedSessionID === currentSessionID); +} + +export function emptySidebarStateText({ loading = false, messageCount = 0 } = {}) { + if (loading) return "loading session metrics"; + return messageCount > 0 ? "waiting for first compression" : "new session"; +} + export function getStatusDotColor(status, theme = {}) { const successColor = theme.success ?? "green"; const errorColor = theme.error ?? "red";