refactor: ai — merge gemini/openrouter into unified plugin architecture
gates / consistency-and-conventions (push) Successful in 1m59s

- bin/pos-ai: single provider-agnostic tool (ask/chat/sessions/capture/models/providers)
- lib/ai-providers/gemini.sh: Gemini adapter (59 ln)
- lib/ai-providers/openrouter.sh: OpenRouter adapter (59 ln)
- bin/pos-ai-gemini/openrouter: thin forwarders for backward compat
- Provider adapter interface: provider_name/default_model/generate/models_list
- Unified session format (OpenAI messages), auto-migrate old gemini contents
- Config: AI_PROVIDER/AI_API_KEY/AI_MODEL/AI_SYSTEM_PROMPT in ai.env
- Config fallback: AI_API_KEY → provider-specific env var → error
- Default system prompt configurable via AI_SYSTEM_PROMPT
- New subcommand: pos ai providers (lists providers + config status)
- Shell hook (pos-ai-hook.sh) for auto-capture
This commit is contained in:
Your Name
2026-08-25 09:57:10 -04:00
parent f0ef13827b
commit 4f79ce123f
14 changed files with 946 additions and 1324 deletions
+94 -66
View File
@@ -2,31 +2,37 @@
Chat with AI models — Gemini, OpenRouter, and more — from the terminal and
through the Telegram bot.
Tools: `gemini` (`ask`, `chat`, `models`), `openrouter` (`ask`, `chat`, `sessions`).
Tool: `pos ai` with pluggable provider adapters (`gemini`, `openrouter`).
| Tool | What it does |
|------|--------------|
| `pos ai gemini ask "<prompt>"` | Answer to stdout (scriptable; terse by default, `--full` for long form). Runs inside the persistent **`default`** session — it remembers prior turns across invocations |
| `pos ai gemini ask --last "why did that fail?"` | Same, but also appends the output of the **most recent logged pos command or captured output** so the model can diagnose a real failure (stderr notes which source + staleness warning) |
| `pos ai gemini capture <cmd>` | Run any command, capture its output for `--last`, and show it on screen |
| `pos ai gemini ask --session <name> "…"` | Same, but uses a named session instead of `default` |
| `pos ai gemini chat` | Interactive multi-turn conversation (session `default` unless `--session`) |
| `pos ai gemini models` | List available model ids |
| `pos ai gemini sessions` | List persistent sessions / clear one (`reset <name>`, e.g. `reset default`) |
| Command | What it does |
|---------|--------------|
| `pos ai ask "<prompt>"` | Answer to stdout (scriptable; terse by default, `--full` for long form). Runs inside the persistent **`default`** session — it remembers prior turns across invocations |
| `pos ai --provider openrouter ask "<prompt>"` | Same, but uses OpenRouter instead of the default Gemini provider |
| `pos ai ask --last "why did that fail?"` | Same, but also appends the output of the **most recent logged pos command or captured output** so the model can diagnose a real failure (stderr notes which source + staleness warning) |
| `pos ai capture <cmd>` | Run any command, capture its output for `--last`, and show it on screen |
| `pos ai ask --session <name> "…"` | Same, but uses a named session instead of `default` |
| `pos ai chat` | Interactive multi-turn conversation (session `default` unless `--session`) |
| `pos ai models` | List available model ids for the active provider |
| `pos ai providers` | List all available providers and their config status |
| `pos ai sessions` | List persistent sessions / clear one (`reset <name>`, e.g. `reset default`) |
Shared flags: `--model <id>` overrides the model; `--system "<text>"` sets the
system instruction for every turn (kept out of the session file) — it replaces
the built-in terse ask prompt wholesale; `--full` skips that built-in prompt
for long-form answers; `--last` attaches the latest pos command output or
captured output (tail, max 4096 chars) to the question and notes on stderr
which source was attached, its age, and a staleness warning once it is older
than an hour (`ask` only; stdout stays pure answer). Use `capture` to save
output from any command for `--last`.
Shared flags: `--provider <name>` selects the backend (gemini|openrouter;
default: gemini; also settable via `AI_PROVIDER` env/config); `--model <id>`
overrides the model; `--system "<text>"` sets the system instruction for every
turn (kept out of the session file) — it replaces the built-in terse ask prompt
wholesale; `--full` skips that built-in prompt for long-form answers; `--last`
attaches the latest pos command output or captured output (tail, max 4096 chars)
to the question and notes on stderr which source was attached, its age, and a
staleness warning once it is older than an hour (`ask` only; stdout stays pure
answer). Use `capture` to save output from any command for `--last`.
Backward compatibility: `pos ai gemini` and `pos ai openrouter` still work as
shorthand for `pos ai --provider gemini` and `pos ai --provider openrouter`.
Every `ask`/`chat` lands in a persistent session file under
`~/.local/share/linux_post_install/ai/<name>.json` (capped at 40 turns).
Terminal work accumulates in `default`; clear it with
`pos ai gemini sessions reset default`.
`pos ai sessions reset default`.
---
@@ -59,15 +65,15 @@ exactly as before (no added blank lines), so scripting stays byte-stable.
2. Configure it (masked input):
```bash
pos config ai # enter AI_GEMINI_API_KEY
pos config ai # enter AI_API_KEY (or AI_GEMINI_API_KEY)
```
3. Test:
```bash
pos ai gemini ask "Explain DNS in one line"
pos ai gemini models # verify the default model id is live
pos ai gemini chat # multi-turn conversation
pos ai ask "Explain DNS in one line"
pos ai models # verify the default model id is live
pos ai chat # multi-turn conversation
```
`ai.env` lives at `~/.config/linux_post_install/ai.env` (chmod 600); `pos config ai`
@@ -77,46 +83,66 @@ is the only place the key is written. The key is never printed by `pos`.
[OpenRouter](https://openrouter.ai) gives access to hundreds of models from
different providers (Anthropic, OpenAI, Meta, Mistral, Google, …) through a
single OpenAI-compatible API. `pos ai openrouter` works identically to the
Gemini tool — same subcommands (`ask`, `chat`, `sessions`), same flags
(`--last`, `--system`, `--full`, `--session`), same terminal rendering and
machine context.
single OpenAI-compatible API. Use `--provider openrouter` to switch:
1. Get an API key from https://openrouter.ai/settings/keys.
```bash
pos ai --provider openrouter ask "hi"
# or the legacy shorthand:
pos ai openrouter ask "hi"
```
2. Configure it:
Configure the API key:
```bash
pos config ai-openrouter # enter OPENROUTER_API_KEY
```
3. Test:
```bash
pos ai openrouter ask "hi"
```
```bash
pos config ai # enter AI_API_KEY (or OPENROUTER_API_KEY)
```
The default model is `openrouter/auto` (OpenRouter picks the best available
provider automatically). Override with `--model provider/model-name`:
```bash
pos ai openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
pos ai --provider openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
```
Sessions are stored separately from Gemini's:
All features work the same way across providers — `--last` for diagnosing
failures, `--system` for custom instructions, `--full` for long-form answers,
persistent sessions, tty markdown rendering, and machine context. Sessions are
shared in `~/.local/share/linux_post_install/ai/` (universal messages format).
```
~/.local/share/linux_post_install/ai-openrouter/<name>.json
Switch providers per-invocation:
```bash
pos ai ask "hello" # uses gemini (default)
pos ai --provider openrouter ask "hello" # uses openrouter
```
All features work the same way — `--last` for diagnosing failures, `--system`
for custom instructions, `--full` for long-form answers, persistent sessions,
tty markdown rendering, and machine context. The only difference is the backend
API.
Or set the default via config:
```bash
pos config ai # set AI_PROVIDER=openrouter
```
## Provider architecture
`pos ai` uses a pluggable provider system. Each provider is a thin adapter
in `lib/ai-providers/<name>.sh` that handles the API-specific logic (auth,
request format, response parsing). The main tool handles sessions, rendering,
machine context, and all shared logic.
Available providers:
| Provider | API | Default model | Config key |
|----------|-----|---------------|------------|
| `gemini` | Google Gemini REST API | `gemini-2.5-flash` | `AI_GEMINI_API_KEY` |
| `openrouter` | OpenRouter (OpenAI-compatible) | `openrouter/auto` | `OPENROUTER_API_KEY` |
Adding a new provider: create `lib/ai-providers/<name>.sh` implementing
`provider_name()`, `provider_default_model()`, `provider_generate()`, and
`provider_models_list()`. See the existing adapters for the interface contract.
## From the Telegram bot
Once `pos ai gemini ask` works, any non-command message starting with `ai ` is
Once `pos ai ask` works, any non-command message starting with `ai ` is
answered by the model — no bot map entry needed:
```
@@ -125,7 +151,7 @@ bot: NVIDIA is a company best known for GPUs...
```
The bridge lives in the Telegram listener's `handle_message` (it calls
`pos ai gemini ask`); only the owner chat is served, so your key stays private.
`pos ai ask`); only the owner chat is served, so your key stays private.
Set a different model per message:
```
@@ -151,17 +177,19 @@ you: ai check this details about my linux ← reply to the /status message
## Recipes
- **Diagnose the last failed pos run:** `pos ai gemini ask --last "why did that fail?"` — every non-interactive `pos <cmd>` logs its output to `~/.local/share/linux_post_install/logs/`; `--last` attaches the newest one (tail, max 4096 chars, errors at the bottom kept) and says on stderr which log it grabbed (name, age, first line). Older than an hour? You get a `[!]` staleness warning — the newest log may predate your current problem, so pipe the fresh failure in instead
- **Pipe arbitrary output in:** `failing-cmd 2>&1 | pos ai gemini ask how do I fix this`
- **Answer from a file:** `pos ai gemini ask "$(cat notes.txt)"`
- **Answer in a cron job:** `pos ai gemini ask "summarize today's git log" > /tmp/ai_digest.txt`
- **Long-form on demand:** `pos ai gemini ask --full "compare ext4 and zfs in depth"`
- **Forget what the terminal asked:** `pos ai gemini sessions reset default`
- **Diagnose the last failed pos run:** `pos ai ask --last "why did that fail?"` — every non-interactive `pos <cmd>` logs its output to `~/.local/share/linux_post_install/logs/`; `--last` attaches the newest one (tail, max 4096 chars, errors at the bottom kept) and says on stderr which log it grabbed (name, age, first line). Older than an hour? You get a `[!]` staleness warning — the newest log may predate your current problem, so pipe the fresh failure in instead
- **Pipe arbitrary output in:** `failing-cmd 2>&1 | pos ai ask how do I fix this`
- **Answer from a file:** `pos ai ask "$(cat notes.txt)"`
- **Answer in a cron job:** `pos ai ask "summarize today's git log" > /tmp/ai_digest.txt`
- **Long-form on demand:** `pos ai ask --full "compare ext4 and zfs in depth"`
- **Forget what the terminal asked:** `pos ai sessions reset default`
- **Switch to OpenRouter:** `pos ai --provider openrouter ask "hi"`
- **Change the default model:**
```bash
pos config ai # set AI_GEMINI_MODEL, or:
AI_GEMINI_MODEL=gemini-2.5-flash pos ai gemini ask "hi"
pos config ai # set AI_MODEL, or:
AI_MODEL=gemini-2.5-flash pos ai ask "hi"
```
- **List available providers:** `pos ai providers`
## Capturing any command's output for --last
@@ -170,8 +198,8 @@ output from **any** command (`pip install`, `apt upgrade`, `make`, etc.):
**Option A — explicit capture:**
```bash
pos ai gemini capture pip install xyz
pos ai gemini ask --last "what happened"
pos ai capture pip install xyz
pos ai ask --last "what happened"
```
The `capture` subcommand runs the command, shows its output on screen, and saves it
for `--last`. Each `capture` overwrites the previous one (latest only).
@@ -187,15 +215,15 @@ truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
## How it works
- `ask` POSTs `contents:[…]` (prior turns of the active session plus the new
user turn) to
`https://generativelanguage.googleapis.com/v1beta/models/<model>:generateContent`
with the key in the `x-goog-api-key` header, and prints
`.candidates[0].content.parts[].text` — nothing else.
- `ask` sends the session history (OpenAI `messages` format) to the active
provider's API. Gemini converts to `contents` format internally; OpenRouter
sends `messages` directly. The answer text is printed to stdout.
- Sessions live as one JSON file per name under
`~/.local/share/linux_post_install/ai/` (`default.json` unless `--session`);
each turn is appended and the file is pruned to the last 40 turns.
- `chat` keeps the whole conversation in memory as a growing `contents[]`
each turn is appended and the file is pruned to the last 40 turns. Old
Gemini-format sessions (`contents[]`) are auto-migrated to `messages` format
on load.
- `chat` keeps the whole conversation in memory as a growing `messages[]`
array (seeded from the session file), so later turns have earlier context.
`/reset` drops it (and empties the session file).
- On a non-2xx response the API's `error.message` is shown and the exit code is
@@ -206,7 +234,7 @@ truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
- `ask` errors "No Gemini API key — run 'pos config ai'" → the key isn't set
(or `ai.env` isn't readable). Run `pos config ai`.
- `API error 400` → the model id is wrong or the prompt is too long for the
model's context window; check `pos ai gemini models`.
model's context window; check `pos ai models`.
- `API error 429` → rate limit (free tier); wait and retry, or use a different
model.
- Nothing in Telegram for `ai …` → the listener daemon must be running