refactor: ai — merge gemini/openrouter into unified plugin architecture
gates / consistency-and-conventions (push) Successful in 1m59s
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:
@@ -10,19 +10,19 @@
|
||||
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 1. Project Overview | 28–43 |
|
||||
| ## 2. Directory Structure | 44–197 |
|
||||
| ## 3. Installation Flow | 198–251 |
|
||||
| ## 4. The `pos` CLI System | 252–326 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 327–358 |
|
||||
| ## 6. Docker Compose / ScaleTail | 359–401 |
|
||||
| ## 7. Optional Apps (`apps/`) | 402–431 |
|
||||
| ## 8. Entertainment Module | 432–445 |
|
||||
| ## 9. Systemd Services | 446–457 |
|
||||
| ## 10. Configuration Files | 458–484 |
|
||||
| ## 11. Coding Conventions | 485–517 |
|
||||
| ## 12. Development Workflow | 518–570 |
|
||||
| ## 13. Key File Quick Reference | 571–638 |
|
||||
| ## 14. Common Tasks for Agents | 639–672 |
|
||||
| ## 2. Directory Structure | 44–198 |
|
||||
| ## 3. Installation Flow | 199–252 |
|
||||
| ## 4. The `pos` CLI System | 253–328 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 329–360 |
|
||||
| ## 6. Docker Compose / ScaleTail | 361–403 |
|
||||
| ## 7. Optional Apps (`apps/`) | 404–433 |
|
||||
| ## 8. Entertainment Module | 434–447 |
|
||||
| ## 9. Systemd Services | 448–459 |
|
||||
| ## 10. Configuration Files | 460–486 |
|
||||
| ## 11. Coding Conventions | 487–519 |
|
||||
| ## 12. Development Workflow | 520–572 |
|
||||
| ## 13. Key File Quick Reference | 573–641 |
|
||||
| ## 14. Common Tasks for Agents | 642–675 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -61,8 +61,8 @@ Linux_post_install/
|
||||
├── bin/ # CLI tools — installed to /usr/local/bin/
|
||||
│ ├── pos # Main dispatcher — smart arg matching to pos-* scripts
|
||||
<!-- GEN:START tree -->
|
||||
│ ├── pos-ai-gemini # Chat with Google Gemini (ask, capture, chat, models, sessions)
|
||||
│ ├── pos-ai-openrouter # Chat with OpenRouter models (ask, capture, chat, models, sessions)
|
||||
│ ├── pos-ai-gemini # Forward to pos ai --provider gemini (backward compat)
|
||||
│ ├── pos-ai-openrouter # Forward to pos ai --provider openrouter (backward compat)
|
||||
│ ├── pos-communication-matrix-listener # Matrix listener: map /command → bash, run them on room messages
|
||||
│ ├── pos-communication-matrix-sender # Send messages to a Matrix room via the client-server API (send, test, login)
|
||||
│ ├── pos-communication-scrcpy # Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info)
|
||||
@@ -97,6 +97,7 @@ Linux_post_install/
|
||||
│ ├── pos-system-firewall # Interactive UFW management
|
||||
│ ├── pos-system-health # Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL
|
||||
│ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
|
||||
│ ├── pos-ai # AI assistant: ask, chat, sessions, capture, models, providers
|
||||
│ ├── pos-config # Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry)
|
||||
│ ├── pos-tree # Show the pos CLI command tree: categories, commands, and subcommands
|
||||
<!-- GEN:END tree -->
|
||||
@@ -268,8 +269,8 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
| Category | Command | Script | Description |
|
||||
|----------|---------|--------|-------------|
|
||||
<!-- GEN:START dispatch -->
|
||||
| ai | gemini | `pos-ai-gemini` | Chat with Google Gemini (ask, capture, chat, models, sessions) |
|
||||
| ai | openrouter | `pos-ai-openrouter` | Chat with OpenRouter models (ask, capture, chat, models, sessions) |
|
||||
| ai | gemini | `pos-ai-gemini` | Forward to pos ai --provider gemini (backward compat) |
|
||||
| ai | openrouter | `pos-ai-openrouter` | Forward to pos ai --provider openrouter (backward compat) |
|
||||
| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages |
|
||||
| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
@@ -304,6 +305,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
| system | firewall | `pos-system-firewall` | Interactive UFW management |
|
||||
| system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
|
||||
| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| | ai | `pos-ai` | AI assistant: ask, chat, sessions, capture, models, providers |
|
||||
| | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
|
||||
| | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
<!-- GEN:END dispatch -->
|
||||
@@ -351,7 +353,7 @@ source "$(dirname "$0")/../lib/common.sh"
|
||||
|
||||
**Scripts that do NOT source common.sh** (self-contained):
|
||||
<!-- GEN:START selfcontained -->
|
||||
`pos`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
|
||||
`pos`, `pos-ai-gemini`, `pos-ai-openrouter`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
|
||||
<!-- GEN:END selfcontained -->
|
||||
|
||||
---
|
||||
@@ -468,7 +470,7 @@ All `.service` files in `systemd/` are automatically copied to `/etc/systemd/sys
|
||||
- `~/.config/linux_post_install/entertainment.env` — entertainment plugin defaults: weather location + `ENABLED` auto-trigger list (`plugin, interval` pairs scheduled via `pos entertainment enable/disable`, systemd user timers); auto-installed from `config/entertainment.env` by `postinstall.sh` (no clobber, template printed)
|
||||
- `~/.config/linux_post_install/system.env` — shared "system" tool settings (loaded by `pos system health` / `pos system backup` via `load_system_env()` in `lib/common.sh`; env already exported wins over the file); template `config/system.env`
|
||||
- `~/.config/linux_post_install/notify.env` — alerting platform selection (`NOTIFY_PLATFORM=telegram,matrix`, comma-separated = fan out); read by `lib/notify.sh`; template `config/notify.env`
|
||||
- `~/.config/linux_post_install/ai.env` — Google Gemini config (`AI_GEMINI_API_KEY` secret, `AI_GEMINI_MODEL` default `gemini-2.5-flash`); read by `pos ai gemini`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
|
||||
- `~/.config/linux_post_install/ai.env` — AI provider config (`AI_PROVIDER`, `AI_API_KEY` secret, `AI_MODEL`, `AI_SYSTEM_PROMPT`, plus legacy fallbacks `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`); read by `pos ai`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
|
||||
- `~/.bashrc` — Modified by postinstall (PATH, bash completion)
|
||||
|
||||
### Feature Flags
|
||||
@@ -592,8 +594,8 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `features/usb-automount.sh` | 138 | USB automount feature (udev rule + flag-gated service) |
|
||||
<!-- GEN:START filetable -->
|
||||
| `bin/pos` | 295 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos-ai-gemini` | 586 | Chat with Google Gemini (ask, capture, chat, models, sessions) |
|
||||
| `bin/pos-ai-openrouter` | 587 | Chat with OpenRouter models (ask, capture, chat, models, sessions) |
|
||||
| `bin/pos-ai-gemini` | 7 | Forward to pos ai --provider gemini (backward compat) |
|
||||
| `bin/pos-ai-openrouter` | 7 | Forward to pos ai --provider openrouter (backward compat) |
|
||||
| `bin/pos-communication-matrix-listener` | 568 | Matrix listener: map /command → bash, run them on room messages |
|
||||
| `bin/pos-communication-matrix-sender` | 224 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| `bin/pos-communication-scrcpy` | 254 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
@@ -628,6 +630,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-system-firewall` | 325 | Interactive UFW management |
|
||||
| `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
|
||||
| `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| `bin/pos-ai` | 642 | AI assistant: ask, chat, sessions, capture, models, providers |
|
||||
| `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
|
||||
| `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
| `completions/pos.bash` | 305 | Dynamic bash completion |
|
||||
@@ -664,7 +667,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| Modify Music→USB sync logic | Edit `bin/pos-media-sync` / shared USB layer `lib/usb-lib.sh` (seams `MEDIA_SYNC_SOURCE`/`MEDIA_SYNC_DEST`/`USB_MOUNT_BASE`/`USB_BYID` in `~/.config/linux_post_install/system.env`) |
|
||||
| Modify YouTube channel sync logic | Edit `bin/pos-media-ytsync` (state in `~/.local/share/linux_post_install/ytsync`; config scope `ytsync` via `pos config ytsync`; research notes `tools-docs/ytsync.md`) |
|
||||
| Modify the scheduler / scheduled jobs | Edit `bin/pos-system-schedule` / `lib/scheduler-lib.sh` (jobs in `~/.config/linux_post_install/schedule.d/`) |
|
||||
| Modify AI/Gemini logic | Edit `bin/pos-ai-gemini` (config scope `ai` via `pos config ai`; `AI_GEMINI_API_KEY`/`AI_GEMINI_MODEL` in `~/.config/linux_post_install/ai.env`) |
|
||||
| Modify AI logic | Edit `bin/pos-ai` (main tool) + `lib/ai-providers/*.sh` (provider adapters); config scope `ai` via `pos config ai`; `AI_API_KEY`/`AI_MODEL`/`AI_PROVIDER` in `~/.config/linux_post_install/ai.env` |
|
||||
| Modify UFW/firewall logic | Edit `bin/pos-system-firewall` |
|
||||
| Modify pos logging | Edit log setup in `bin/pos` |
|
||||
| Modify install phases/flags | Edit arg parsing in `install.sh` |
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ templates (without overwriting an existing file):
|
||||
| `system.env` | `pos system health`, `pos system backup` | `BACKUP_SERVICE_ROOTS`, `HEALTH_BACKUP_MAX_AGE_DAYS` |
|
||||
| `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` |
|
||||
| `entertainment.env` | `pos entertainment *` | plugin keys (`WEATHER_LAT`…), `ENABLED` |
|
||||
| `ai.env` | `pos ai gemini` | `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL` |
|
||||
| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL` |
|
||||
| `schedule.d/` | `pos system schedule` | one `<name>.env` per job: `INTERVAL`, `NOTIFY`, `MSG`, `RULE`, `COMMAND` |
|
||||
|
||||
```bash
|
||||
|
||||
+25
-35
@@ -55,52 +55,42 @@ Category-less tools (`config`, `tree`) live outside any category and are documen
|
||||
|
||||
### ai
|
||||
|
||||
**File:** `bin/pos-ai-gemini`
|
||||
**Purpose:** chat with Google Gemini via the REST API (`generativelanguage.googleapis.com`). One tool, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), and `models` (list `generateContent`-capable ids).
|
||||
**File:** `bin/pos-ai` (provider-agnostic main tool), `bin/pos-ai-gemini` / `bin/pos-ai-openrouter` (backward-compat forwarders → `pos ai --provider <name>`)
|
||||
**Provider adapters:** `lib/ai-providers/gemini.sh`, `lib/ai-providers/openrouter.sh`
|
||||
**Purpose:** AI assistant with pluggable providers. Six subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), `models` (list available models), `providers` (list providers and config status), and `sessions` (list/clear sessions). Providers handle API-specific logic; the main tool handles sessions, rendering, machine context, and all shared logic.
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos ai gemini ask "<prompt>"` | POSTs `generateContent` and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
|
||||
| `pos ai gemini capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
|
||||
| `pos ai gemini chat` | Interactive REPL with multi-turn history (the `contents[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai gemini sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `pos ai gemini models` | Lists models that support `generateContent` and flags the configured default |
|
||||
| `pos ai gemini --model <id> …` | Overrides the model for one invocation |
|
||||
| `pos ai ask "<prompt>"` | Sends the prompt to the active provider (default: gemini) and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it; `AI_SYSTEM_PROMPT` env/config provides a custom default). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
|
||||
| `pos ai --provider openrouter ask "<prompt>"` | Same, but uses OpenRouter instead of the default Gemini provider |
|
||||
| `pos ai capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
|
||||
| `pos ai chat` | Interactive REPL with multi-turn history (the `messages[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `pos ai models` | Lists available models for the active provider and flags the configured default |
|
||||
| `pos ai providers` | Lists available providers, their config status, and the active provider |
|
||||
| `pos ai --model <id> …` | Overrides the model for one invocation |
|
||||
| `pos ai --provider <name> …` | Selects the provider for one invocation (gemini\|openrouter) |
|
||||
|
||||
`pos ai gemini` with no subcommand prints usage (never blocks on stdin). `ask`/`chat` time out after 60s per request; on a non-2xx response the API's `error.message` is shown and the tool exits non-zero.
|
||||
Backward compatibility: `pos ai gemini` and `pos ai openrouter` still work as shorthands for `pos ai --provider gemini` and `pos ai --provider openrouter`.
|
||||
|
||||
`pos ai` with no subcommand prints usage (never blocks on stdin). `ask`/`chat` time out after 60s per request; on a non-2xx response the API's `error.message` is shown and the tool exits non-zero.
|
||||
|
||||
**Configuration** (`~/.config/linux_post_install/ai.env`, edit with `pos config ai`):
|
||||
|
||||
| Key | Required | Default | Purpose |
|
||||
|-----|----------|---------|---------|
|
||||
| `AI_GEMINI_API_KEY` | yes | — | API key from aistudio.google.com (secret — masked in `pos config ai`) |
|
||||
| `AI_GEMINI_MODEL` | no | `gemini-2.5-flash` | Model id used by `ask`/`chat`/`models` |
|
||||
| `AI_PROVIDER` | no | `gemini` | Active provider (gemini\|openrouter) |
|
||||
| `AI_API_KEY` | yes | — | API key for the active provider (secret — masked in `pos config ai`) |
|
||||
| `AI_MODEL` | no | per provider | Model id used by `ask`/`chat`/`models` |
|
||||
| `AI_SYSTEM_PROMPT` | no | built-in terse prompt | Custom system prompt (overrides built-in; empty to reset) |
|
||||
| `AI_GEMINI_API_KEY` | fallback | — | Legacy: Gemini API key (used when `AI_API_KEY` is empty) |
|
||||
| `AI_GEMINI_MODEL` | fallback | `gemini-2.5-flash` | Legacy: Gemini model id (used when `AI_MODEL` is empty) |
|
||||
| `OPENROUTER_API_KEY` | fallback | — | Legacy: OpenRouter API key (used when `AI_API_KEY` is empty) |
|
||||
| `OPENROUTER_MODEL` | fallback | `openrouter/auto` | Legacy: OpenRouter model id (used when `AI_MODEL` is empty) |
|
||||
|
||||
Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-flash`. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES).
|
||||
Model precedence: `--model` flag > `AI_MODEL` env > provider-specific fallback (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`) > provider default. API key precedence: `AI_API_KEY` env > provider-specific fallback (`AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY`) > error. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). Sessions are stored in OpenAI `messages` format universally; old Gemini-format sessions (`contents[]`) are auto-migrated on load.
|
||||
|
||||
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai gemini ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`).
|
||||
|
||||
**File:** `bin/pos-ai-openrouter`
|
||||
**Purpose:** chat with OpenRouter models via the REST API (`openrouter.ai`). One tool, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), and `sessions` (list/clear sessions). OpenRouter provides access to hundreds of models from different providers through a single OpenAI-compatible API.
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos ai openrouter ask "<prompt>"` | POSTs a chat completion and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai-openrouter/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
|
||||
| `pos ai openrouter capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
|
||||
| `pos ai openrouter chat` | Interactive REPL with multi-turn history (the `messages[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai openrouter sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `pos ai openrouter --model <id> …` | Overrides the model for one invocation |
|
||||
|
||||
`pos ai openrouter` with no subcommand prints usage (never blocks on stdin). `ask`/`chat` time out after 60s per request; on a non-2xx response the API's `error.message` is shown and the tool exits non-zero.
|
||||
|
||||
**Configuration** (`~/.config/linux_post_install/ai-openrouter.env`, edit with `pos config ai-openrouter`):
|
||||
|
||||
| Key | Required | Default | Purpose |
|
||||
|-----|----------|---------|---------|
|
||||
| `OPENROUTER_API_KEY` | yes | — | API key from openrouter.ai (secret — masked in `pos config ai-openrouter`) |
|
||||
| `OPENROUTER_MODEL` | no | `openrouter/auto` | Model id used by `ask`/`chat`/`models` |
|
||||
|
||||
Precedence: `--model` flag > `OPENROUTER_MODEL` env > config file > `openrouter/auto`. Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES).
|
||||
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`).
|
||||
|
||||
### network
|
||||
|
||||
|
||||
+94
-66
@@ -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
|
||||
|
||||
@@ -259,7 +259,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
|
||||
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
|
||||
|
||||
# Commands that read from stdin interactively — only log invocation
|
||||
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini ai-openrouter system-schedule entertainment-config config"
|
||||
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter system-schedule entertainment-config config"
|
||||
|
||||
for ((i=n-1; i>=0; i--)); do
|
||||
cmd="pos"
|
||||
|
||||
Executable
+642
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai ask — AI assistant: ask, chat, sessions, capture, models, providers
|
||||
# POS_SUBCMDS: ask chat sessions capture models providers
|
||||
# POS_FLAGS: --provider --model --session --system --full --last
|
||||
# POS_CONFIG: ai | ai.env | AI_PROVIDER=:Provider (gemini|openrouter, default gemini) | AI_API_KEY=secret:API key for the active provider | AI_MODEL=:Model id (default per provider) | AI_SYSTEM_PROMPT=:Custom system prompt (overrides built-in; empty to reset)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
# ── Paths & constants ──────────────────────────────────────────
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
|
||||
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
|
||||
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
|
||||
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
|
||||
PROVIDER_DIR="$(dirname "$0")/../lib/ai-providers"
|
||||
# Fallback for installed layout (flat /usr/local/bin)
|
||||
[ -d "$PROVIDER_DIR" ] || PROVIDER_DIR="$(dirname "$0")/ai-providers"
|
||||
|
||||
SESSION="default"
|
||||
SYSTEM_PROMPT=""
|
||||
MAX_SESSION_TURNS=40
|
||||
LAST_LOG_MAX_BYTES=4096
|
||||
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
|
||||
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
|
||||
# (see machine_context) unless --system replaces it or --full drops everything.
|
||||
DEFAULT_SYSTEM_PROMPT_HARD="You are a Linux CLI assistant. Rules:
|
||||
1. Lead with exact command(s) — no explanations unless asked
|
||||
2. One line max per command; short bullets for multi-step only
|
||||
3. No greetings, no pleasantries, no closing offers
|
||||
4. For errors: diagnose and give the fix command first
|
||||
5. Match the user's OS/package manager (apt/dnf/pacman)"
|
||||
|
||||
# Legacy: kept for session migration and backward compat config
|
||||
LEGACY_GEMINI_CONFIG="$HOME/.config/linux_post_install/ai.env"
|
||||
LEGACY_OPENROUTER_CONFIG="$HOME/.config/linux_post_install/ai-openrouter.env"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai [subcommand] [--provider <name>] [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
AI assistant with pluggable providers (gemini, openrouter).
|
||||
|
||||
Subcommands:
|
||||
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
|
||||
also be piped in via stdin when no argument is given.
|
||||
Runs inside the persistent 'default' session (prior turns
|
||||
are sent as context); --session <name> picks another.
|
||||
capture <cmd..> Run a command, capture its output for --last, and show it.
|
||||
Each capture overwrites the previous one (latest only).
|
||||
chat Interactive multi-turn conversation (session 'default'
|
||||
unless --session is given).
|
||||
models List available models for the active provider.
|
||||
providers List available providers and their config status.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--provider <name> Provider to use (gemini|openrouter; default: gemini).
|
||||
Can also be set via AI_PROVIDER env/config.
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns).
|
||||
--system <text> System instruction sent with every turn (kept out of the
|
||||
session file); replaces the built-in terse ask prompt
|
||||
wholesale, e.g. "Reply like a friendly Telegram chat".
|
||||
--full Skip the built-in terse prompt — long-form answers.
|
||||
--last ask only: attach the most recent pos dispatcher log or
|
||||
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
|
||||
the model can diagnose a real failure. Sources in priority
|
||||
order: (1) newest pos log, (2) captured output from
|
||||
'capture'. Notes on stderr which source was attached and
|
||||
its age; warns when stale (>60 min).
|
||||
-h|--help This help.
|
||||
|
||||
Config: $CONFIG_FILE (edit with 'pos config ai')
|
||||
AI_PROVIDER Provider to use (gemini|openrouter, default gemini)
|
||||
AI_API_KEY API key for the active provider (secret)
|
||||
AI_MODEL Model id (default depends on provider)
|
||||
AI_SYSTEM_PROMPT Custom system prompt (overrides built-in; empty to reset)
|
||||
AI_GEMINI_API_KEY Legacy: Gemini API key fallback
|
||||
AI_GEMINI_MODEL Legacy: Gemini model fallback
|
||||
OPENROUTER_API_KEY Legacy: OpenRouter API key fallback
|
||||
OPENROUTER_MODEL Legacy: OpenRouter model fallback
|
||||
|
||||
Notes:
|
||||
ask is terse by default: a built-in system instruction tells the model to
|
||||
lead with the exact commands and keep prose minimal — and to diagnose pasted
|
||||
errors/output with a fix first. That prompt ends with one machine-context
|
||||
line (hostname, distro, kernel, arch detected on this box) so answers fit
|
||||
the actual machine; --system replaces it wholesale, --full drops it all.
|
||||
Every ask/chat lands in a persistent session ('default' unless --session);
|
||||
clear it with 'pos ai sessions reset default'. On a terminal the
|
||||
answer is rendered as markdown (glow if installed, else a built-in
|
||||
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
|
||||
raw markdown is printed unchanged.
|
||||
|
||||
Examples:
|
||||
pos ai ask "check disk space on /"
|
||||
pos ai --provider gemini ask "Explain DNS in one line"
|
||||
pos ai --provider openrouter ask "hi"
|
||||
echo "summarize this log" | pos ai ask
|
||||
failing-cmd 2>&1 | pos ai ask how do I fix this
|
||||
pos ai ask --last "why did that fail?" # attach last output
|
||||
pos ai capture pip install xyz # capture any command
|
||||
pos ai ask --last "what happened?" # after capture
|
||||
pos ai chat
|
||||
pos ai models
|
||||
pos ai providers
|
||||
pos ai ask --model gemini-2.5-flash "hi"
|
||||
pos ai ask --system "Reply like a pirate" "explain chmod"
|
||||
pos ai ask --session work "my name is joe"
|
||||
pos ai ask --session work "what is my name?" # remembers
|
||||
pos ai sessions
|
||||
pos ai sessions reset default # forget default memory
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Provider loading ───────────────────────────────────────────
|
||||
load_provider() {
|
||||
local p="${PROVIDER:-gemini}"
|
||||
local f="$PROVIDER_DIR/$p.sh"
|
||||
[ -f "$f" ] || err "Unknown provider '$p' — available: $(ls "$PROVIDER_DIR"/*.sh 2>/dev/null | xargs -I{} basename {} .sh | tr '\n' ' ')"
|
||||
# shellcheck source=/dev/null
|
||||
source "$f"
|
||||
}
|
||||
|
||||
# ── ai.env loader (same pattern as telegram.env) ────────────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
# Legacy provider-specific config files (fallback for old configs)
|
||||
# Both files are loaded — env-var precedence means unified AI_API_KEY wins.
|
||||
local legacy_files="$LEGACY_GEMINI_CONFIG $LEGACY_OPENROUTER_CONFIG"
|
||||
local legacy_env
|
||||
for legacy_env in $legacy_files; do
|
||||
[ -f "$legacy_env" ] && [ "$legacy_env" != "$CONFIG_FILE" ] || continue
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in \#*) continue ;; esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$legacy_env" || true)
|
||||
done
|
||||
}
|
||||
|
||||
# ── Config resolution ──────────────────────────────────────────
|
||||
resolve_key() {
|
||||
load_config
|
||||
local p="${PROVIDER:-gemini}"
|
||||
# 1. Unified AI_API_KEY
|
||||
if [ -n "${AI_API_KEY:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
# 2. Provider-specific fallback
|
||||
case "$p" in
|
||||
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && export AI_API_KEY="$AI_GEMINI_API_KEY" && return 0 ;;
|
||||
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && export AI_API_KEY="$OPENROUTER_API_KEY" && return 0 ;;
|
||||
esac
|
||||
# 3. No key found — return 1 (require_key handles the error message)
|
||||
return 1
|
||||
}
|
||||
|
||||
require_key() {
|
||||
if ! resolve_key >/dev/null 2>&1; then
|
||||
local p="${PROVIDER:-gemini}"
|
||||
case "$p" in
|
||||
gemini) err "No Gemini API key — run 'pos config ai' or set AI_API_KEY / AI_GEMINI_API_KEY" ;;
|
||||
openrouter) err "No OpenRouter API key — run 'pos config ai' or set AI_API_KEY / OPENROUTER_API_KEY" ;;
|
||||
esac
|
||||
err "No API key for provider '$p' — run 'pos config ai' or set AI_API_KEY"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_model() {
|
||||
local p="${PROVIDER:-gemini}"
|
||||
if [ -n "${MODEL_OVERRIDE:-}" ]; then
|
||||
printf '%s' "$MODEL_OVERRIDE"
|
||||
elif [ -n "${AI_MODEL:-}" ]; then
|
||||
printf '%s' "$AI_MODEL"
|
||||
else
|
||||
# Provider-specific fallback
|
||||
case "$p" in
|
||||
gemini) [ -n "${AI_GEMINI_MODEL:-}" ] && printf '%s' "$AI_GEMINI_MODEL" && return ;;
|
||||
openrouter) [ -n "${OPENROUTER_MODEL:-}" ] && printf '%s' "$OPENROUTER_MODEL" && return ;;
|
||||
esac
|
||||
provider_default_model
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
newest_pos_log() {
|
||||
[ -d "$DISPATCH_LOG_DIR" ] || return 1
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
|
||||
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
|
||||
return 1
|
||||
}
|
||||
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
if command -v iconv >/dev/null 2>&1; then
|
||||
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
|
||||
fi
|
||||
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
|
||||
printf '[…truncated…]\n%s' "$raw"
|
||||
else
|
||||
printf '%s' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
human_age() {
|
||||
local s="$1"
|
||||
[ "$s" -lt 0 ] && s=0
|
||||
if [ "$s" -lt 60 ]; then printf 'just now'
|
||||
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
|
||||
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
|
||||
else printf '%sd' "$((s / 86400))"
|
||||
fi
|
||||
}
|
||||
|
||||
last_log_annotate() {
|
||||
local f="$1" age_s age line
|
||||
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
|
||||
[ "$age_s" -lt 0 ] && age_s=0
|
||||
age="$(human_age "$age_s")"
|
||||
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
|
||||
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
|
||||
if [ -n "$line" ]; then
|
||||
printf '[i] "%.100s"\n' "$line" >&2
|
||||
fi
|
||||
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
|
||||
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai ask "what happened"\n' "$age" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory (universal OpenAI messages format) ─
|
||||
session_file() {
|
||||
local name="${1:-$SESSION}"
|
||||
name="${name//[^A-Za-z0-9_-]/_}"
|
||||
printf '%s/%s.json' "$SESSION_DIR" "$name"
|
||||
}
|
||||
|
||||
session_load() {
|
||||
[ -n "$SESSION" ] || { printf '{"messages":[]}'; return 0; }
|
||||
local f
|
||||
f="$(session_file)"
|
||||
if [ -s "$f" ]; then
|
||||
# Check for old Gemini contents format → migrate transparently
|
||||
if jq -e '.contents' "$f" >/dev/null 2>&1 && ! jq -e '.messages' "$f" >/dev/null 2>&1; then
|
||||
local migrated
|
||||
migrated="$(jq -c '{messages: [.contents[]? | {role: (if .role == "model" then "assistant" else .role end), content: (.parts | map(.text) | join(""))}]}' "$f" 2>/dev/null)" || {
|
||||
printf '{"messages":[]}'; return 0
|
||||
}
|
||||
printf '%s\n' "$migrated" > "$f"
|
||||
chmod 600 "$f"
|
||||
printf '%s' "$migrated"
|
||||
elif jq -e '.messages' "$f" >/dev/null 2>&1; then
|
||||
cat "$f"
|
||||
else
|
||||
printf '{"messages":[]}'
|
||||
fi
|
||||
else
|
||||
printf '{"messages":[]}'
|
||||
fi
|
||||
}
|
||||
|
||||
session_save() {
|
||||
[ -n "$SESSION" ] || return 0
|
||||
local f tmp
|
||||
f="$(session_file)"
|
||||
mkdir -p "$SESSION_DIR"
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$1" >"$tmp"
|
||||
mv "$tmp" "$f"
|
||||
chmod 600 "$f"
|
||||
}
|
||||
|
||||
# Append a turn and prune to the last MAX_SESSION_TURNS entries. stdout = JSON.
|
||||
session_push() {
|
||||
local messages="$1" role="$2" text="$3"
|
||||
printf '%s' "$messages" | jq -c --arg r "$role" --arg t "$text" \
|
||||
'.messages += [{role:$r, content:$t}] | .messages |= .[-'"$MAX_SESSION_TURNS"':]'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
if [ ! -t 1 ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
prog='
|
||||
BEGIN {
|
||||
e = sprintf("%c", 27)
|
||||
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
|
||||
YEL = e "[33m"; CYA = e "[1;36m"
|
||||
RULE = ""
|
||||
for (i = 0; i < 60; i++) RULE = RULE "─"
|
||||
RULE = DIM RULE R
|
||||
}
|
||||
/^```/ { fence = !fence; next }
|
||||
fence { printf "%s %s%s\n", DIM, $0, R; next }
|
||||
/^#{1,4} / {
|
||||
sub(/^#{1,4} +/, "")
|
||||
printf "%s%s%s\n", CYA, $0, R
|
||||
next
|
||||
}
|
||||
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
|
||||
{
|
||||
line = $0
|
||||
out = ""; rest = line
|
||||
while (match(rest, /`[^`]*`/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) YEL \
|
||||
substr(rest, RSTART + 1, RLENGTH - 2) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /\*\*[^*]+\*\*/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /__[^_]+__/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print out rest
|
||||
}
|
||||
'
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
rendered="$(printf '%s\n' "$text" | glow -)"
|
||||
else
|
||||
rendered="$(printf '%s\n' "$text" | awk "$prog")"
|
||||
fi
|
||||
printf '\n%s\n' "$rendered"
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
mc_clean() {
|
||||
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
|
||||
-e 's/[[:space:]][[:space:]]*/ /g' \
|
||||
| tr -d '\000-\010\013-\037\177' \
|
||||
| sed -e 's/^ //; s/ $//'
|
||||
}
|
||||
|
||||
machine_context() {
|
||||
local raw line key val h="" o="" k="" a="" part out=""
|
||||
if command -v hostnamectl >/dev/null 2>&1; then
|
||||
raw="$(hostnamectl status 2>/dev/null || true)"
|
||||
while IFS= read -r line; do
|
||||
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
|
||||
val="${line#*:}"
|
||||
case "$key" in
|
||||
Statichostname|Transienthostname|Hostname)
|
||||
[ -z "$h" ] && h="$val" ;;
|
||||
OperatingSystem)
|
||||
[ -z "$o" ] && o="$val" ;;
|
||||
Kernel)
|
||||
[ -z "$k" ] && k="$val" ;;
|
||||
Architecture)
|
||||
[ -z "$a" ] && a="$val" ;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
fi
|
||||
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
|
||||
o="$(
|
||||
. "$OS_RELEASE_FILE" 2>/dev/null || true
|
||||
if [ -n "${PRETTY_NAME:-}" ]; then
|
||||
printf '%s' "$PRETTY_NAME"
|
||||
elif [ -n "${NAME:-}" ]; then
|
||||
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
|
||||
fi
|
||||
)"
|
||||
fi
|
||||
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
|
||||
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
|
||||
h="$(printf '%s' "$h" | mc_clean)"
|
||||
o="$(printf '%s' "$o" | mc_clean)"
|
||||
k="$(printf '%s' "$k" | mc_clean)"
|
||||
a="$(printf '%s' "$a" | mc_clean)"
|
||||
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
|
||||
local out=""
|
||||
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
|
||||
[ -n "$part" ] || continue
|
||||
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
|
||||
done
|
||||
[ -n "$out" ] || return 0
|
||||
printf 'Machine context (answers must fit this box): %s.' "$out"
|
||||
}
|
||||
|
||||
# ── Subcommands ────────────────────────────────────────────────
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai capture <command> [args...]"
|
||||
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
|
||||
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
|
||||
return $rc
|
||||
}
|
||||
|
||||
cmd_ask() {
|
||||
local prompt="" messages out system ctx mc
|
||||
if [ $# -gt 0 ]; then
|
||||
prompt="$*"
|
||||
elif [ ! -t 0 ]; then
|
||||
prompt="$(cat)"
|
||||
fi
|
||||
[ -n "$prompt" ] || err "No prompt given — usage: pos ai ask \"<prompt>\""
|
||||
# --last: append the most recent logged pos command output AFTER the
|
||||
# question, so the model diagnoses the real failure.
|
||||
if [ "$LAST_MODE" -eq 1 ]; then
|
||||
local log_file="" pos_log="" captured_log=""
|
||||
pos_log="$(newest_pos_log 2>/dev/null)" || true
|
||||
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
|
||||
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
|
||||
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
|
||||
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
|
||||
if [ "$cap_age" -lt "$pos_age" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
elif [ -n "$captured_log" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai capture <cmd>' first, or pipe: cmd 2>&1 | pos ai ask \"what happened\""
|
||||
last_log_annotate "$log_file"
|
||||
ctx="$(last_log_context "$log_file")"
|
||||
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
|
||||
fi
|
||||
require_key
|
||||
# Terse by default: user --system replaces the built-in prompt wholesale;
|
||||
# --full skips everything (built-in text AND machine context).
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
# Check AI_SYSTEM_PROMPT config first, then fall back to built-in
|
||||
system="${AI_SYSTEM_PROMPT:-}"
|
||||
if [ -z "$system" ]; then
|
||||
system="$DEFAULT_SYSTEM_PROMPT_HARD"
|
||||
fi
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$system$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
messages="$(session_load)"
|
||||
messages="$(session_push "$messages" user "$prompt")"
|
||||
if ! out="$(provider_generate "$(resolve_model)" "$messages" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$out")"
|
||||
session_save "$messages"
|
||||
render_markdown "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model messages text answer provider_display
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
provider_display="$(provider_name)"
|
||||
messages="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$messages" | jq -r '.messages | length')"
|
||||
trap 'echo; echo "bye"; exit 0' INT
|
||||
echo "${provider_display} · ${model} — type a message; q=quit, /reset=clear history"
|
||||
while true; do
|
||||
printf '> '
|
||||
IFS= read -r text || break
|
||||
case "$text" in
|
||||
"" ) continue ;;
|
||||
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
|
||||
/reset)
|
||||
messages='{"messages":[]}'
|
||||
session_save "$messages"
|
||||
echo "[history cleared]"
|
||||
continue ;;
|
||||
esac
|
||||
messages="$(session_push "$messages" user "$text")"
|
||||
if ! answer="$(provider_generate "$model" "$messages" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
warn "AI error: $answer"
|
||||
continue
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$answer")"
|
||||
session_save "$messages"
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
printf '\n\n'
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_sessions() {
|
||||
local action="${1:-list}" name f n
|
||||
case "$action" in
|
||||
list|"")
|
||||
[ -d "$SESSION_DIR" ] || { echo "no sessions"; return 0; }
|
||||
local found=0
|
||||
for f in "$SESSION_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
found=1
|
||||
n="$(jq -r '.messages | length' "$f" 2>/dev/null || echo 0)"
|
||||
printf ' %-32s %s turns\n' "$(basename "$f" .json)" "${n:-0}"
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo "no sessions"
|
||||
;;
|
||||
reset)
|
||||
[ $# -ge 2 ] || err "usage: pos ai sessions reset <name>"
|
||||
name="$2"
|
||||
if rm -f "$(session_file "$name")"; then
|
||||
ok "session '$name' cleared"
|
||||
fi
|
||||
;;
|
||||
*) err "Unknown sessions subcommand '$action' (list | reset <name>)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_models() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for models: $*"
|
||||
local model
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
provider_models_list "$model"
|
||||
}
|
||||
|
||||
cmd_providers() {
|
||||
echo "Available providers:"
|
||||
load_config # ensure env vars are populated
|
||||
local active="${PROVIDER:-gemini}"
|
||||
for f in "$PROVIDER_DIR"/*.sh; do
|
||||
[ -f "$f" ] || continue
|
||||
local name pname pmodel configured current
|
||||
name="$(basename "$f" .sh)"
|
||||
# Source provider in a subshell to get its metadata
|
||||
local meta
|
||||
meta="$( ( source "$f"; printf '%s\x00%s' "$(provider_name)" "$(provider_default_model)" ) 2>/dev/null )" || true
|
||||
pname="${meta%%$'\x00'*}"
|
||||
pmodel="${meta#*$'\x00'}"
|
||||
[ -n "$pname" ] || pname="$name"
|
||||
[ -n "$pmodel" ] || pmodel="unknown"
|
||||
# Check if API key exists for this provider
|
||||
configured="not configured"
|
||||
if [ -n "${AI_API_KEY:-}" ]; then
|
||||
configured="configured"
|
||||
else
|
||||
case "$name" in
|
||||
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && configured="configured" ;;
|
||||
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && configured="configured" ;;
|
||||
esac
|
||||
fi
|
||||
current=""
|
||||
[ "$name" = "$active" ] && current=" ← active"
|
||||
printf ' %-16s %s (model: %s)%s\n' "$name" "$configured" "$pmodel" "$current"
|
||||
done
|
||||
}
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
PROVIDER=""
|
||||
cmd=""
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--provider)
|
||||
[ $# -ge 2 ] || err "--provider needs a value"
|
||||
PROVIDER="$2"; shift 2 ;;
|
||||
--model)
|
||||
[ $# -ge 2 ] || err "--model needs a value"
|
||||
MODEL_OVERRIDE="$2"; shift 2 ;;
|
||||
--session)
|
||||
[ $# -ge 2 ] || err "--session needs a value"
|
||||
SESSION="$2"; shift 2 ;;
|
||||
--system)
|
||||
[ $# -ge 2 ] || err "--system needs a value"
|
||||
SYSTEM_PROMPT="$2"; shift 2 ;;
|
||||
--full)
|
||||
FULL_MODE=1; shift ;;
|
||||
--last)
|
||||
LAST_MODE=1; shift ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve provider: --provider flag > AI_PROVIDER env/config > default gemini
|
||||
if [ -z "$PROVIDER" ]; then
|
||||
load_config
|
||||
PROVIDER="${AI_PROVIDER:-gemini}"
|
||||
fi
|
||||
|
||||
# Load provider adapter functions
|
||||
load_provider
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
providers) cmd_providers "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
+5
-594
@@ -1,596 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai gemini — Chat with Google Gemini (ask, capture, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask capture chat models sessions
|
||||
# POS_FLAGS: --model --session --system --full --last
|
||||
# POS_CONFIG: ai | ai.env | AI_GEMINI_API_KEY=secret:API key from aistudio.google.com | AI_GEMINI_MODEL=:Model id (default gemini-2.5-flash)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
|
||||
API="https://generativelanguage.googleapis.com/v1beta"
|
||||
DEFAULT_MODEL="gemini-2.5-flash"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
|
||||
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
|
||||
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
|
||||
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
|
||||
SESSION="default"
|
||||
SYSTEM_PROMPT=""
|
||||
MAX_SESSION_TURNS=40
|
||||
LAST_LOG_MAX_BYTES=4096
|
||||
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
|
||||
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
|
||||
# (see machine_context) unless --system replaces it or --full drops everything.
|
||||
DEFAULT_SYSTEM_PROMPT="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
Chat with Google Gemini via the REST API (generativelanguage.googleapis.com).
|
||||
|
||||
Subcommands:
|
||||
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
|
||||
also be piped in via stdin when no argument is given.
|
||||
Runs inside the persistent 'default' session (prior turns
|
||||
are sent as context); --session <name> picks another.
|
||||
capture <cmd..> Run a command, capture its output for --last, and show it.
|
||||
Each capture overwrites the previous one (latest only).
|
||||
chat Interactive multi-turn conversation (session 'default'
|
||||
unless --session is given).
|
||||
models List models that support generateContent.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns).
|
||||
--system <text> System instruction sent with every turn (kept out of the
|
||||
session file); replaces the built-in terse ask prompt
|
||||
wholesale, e.g. "Reply like a friendly Telegram chat".
|
||||
--full Skip the built-in terse prompt — long-form answers.
|
||||
--last ask only: attach the most recent pos dispatcher log or
|
||||
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
|
||||
the model can diagnose a real failure. Sources in priority
|
||||
order: (1) newest pos log, (2) captured output from
|
||||
'capture'. Notes on stderr which source was attached and
|
||||
its age; warns when stale (>60 min).
|
||||
-h|--help This help.
|
||||
|
||||
Config: $CONFIG_FILE (edit with 'pos config ai')
|
||||
AI_GEMINI_API_KEY API key from aistudio.google.com (required)
|
||||
AI_GEMINI_MODEL Model id (default $DEFAULT_MODEL)
|
||||
|
||||
Notes:
|
||||
ask is terse by default: a built-in system instruction tells the model to
|
||||
lead with the exact commands and keep prose minimal — and to diagnose pasted
|
||||
errors/output with a fix first. That prompt ends with one machine-context
|
||||
line (hostname, distro, kernel, arch detected on this box) so answers fit
|
||||
the actual machine; --system replaces it wholesale, --full drops it all.
|
||||
Every ask/chat lands in a persistent session ('default' unless --session);
|
||||
clear it with 'pos ai gemini sessions reset default'. On a terminal the
|
||||
answer is rendered as markdown (glow if installed, else a built-in
|
||||
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
|
||||
raw markdown is printed unchanged.
|
||||
|
||||
Examples:
|
||||
pos ai gemini ask "check disk space on /"
|
||||
pos ai gemini ask --full "Explain DNS in depth"
|
||||
echo "summarize this log" | pos ai gemini ask
|
||||
failing-cmd 2>&1 | pos ai gemini ask how do I fix this
|
||||
pos ai gemini ask --last "why did that fail?" # attach last output
|
||||
pos ai gemini capture pip install xyz # capture any command
|
||||
pos ai gemini ask --last "what happened?" # after capture
|
||||
pos ai gemini chat
|
||||
pos ai gemini models
|
||||
pos ai gemini ask --model gemini-2.5-flash "hi"
|
||||
pos ai gemini ask --system "Reply like a pirate" "explain chmod"
|
||||
pos ai gemini ask --session work "my name is joe"
|
||||
pos ai gemini ask --session work "what is my name?" # remembers
|
||||
pos ai gemini sessions
|
||||
pos ai gemini sessions reset default # forget default memory
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── ai.env loader (same pattern as telegram.env) ────────────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
}
|
||||
|
||||
require_key() {
|
||||
load_config
|
||||
[ -n "${AI_GEMINI_API_KEY:-}" ] || err "No Gemini API key — run 'pos config ai'"
|
||||
}
|
||||
|
||||
resolve_model() {
|
||||
if [ -n "${MODEL_OVERRIDE:-}" ]; then
|
||||
printf '%s' "$MODEL_OVERRIDE"
|
||||
elif [ -n "${AI_GEMINI_MODEL:-}" ]; then
|
||||
printf '%s' "$AI_GEMINI_MODEL"
|
||||
else
|
||||
printf '%s' "$DEFAULT_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
|
||||
# (ai-gemini itself is interactive-logged, so it never creates its own output
|
||||
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
|
||||
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
|
||||
# index, not command output — excluded.
|
||||
newest_pos_log() {
|
||||
[ -d "$DISPATCH_LOG_DIR" ] || return 1
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
|
||||
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
|
||||
return 1
|
||||
}
|
||||
|
||||
# stdout = the context block body for log $1: its END kept (errors live at
|
||||
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
# a byte cut can split a multibyte char — drop invalid sequences when possible
|
||||
if command -v iconv >/dev/null 2>&1; then
|
||||
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
|
||||
fi
|
||||
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
|
||||
printf '[…truncated…]\n%s' "$raw"
|
||||
else
|
||||
printf '%s' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last transparency (stderr-only; stdout stays pure answer) ──
|
||||
# Seconds → human age: just now / Nm / Nh / Nd.
|
||||
human_age() {
|
||||
local s="$1"
|
||||
[ "$s" -lt 0 ] && s=0
|
||||
if [ "$s" -lt 60 ]; then printf 'just now'
|
||||
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
|
||||
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
|
||||
else printf '%sd' "$((s / 86400))"
|
||||
fi
|
||||
}
|
||||
|
||||
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
|
||||
# so a misread of an ancient log as the current failure is visible before
|
||||
# the model answers. $1 = log file (as recorded by last_log_context).
|
||||
last_log_annotate() {
|
||||
local f="$1" age_s age line
|
||||
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
|
||||
[ "$age_s" -lt 0 ] && age_s=0
|
||||
age="$(human_age "$age_s")"
|
||||
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
|
||||
# preview: first meaningful line of the log (blank lines skipped)
|
||||
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
|
||||
if [ -n "$line" ]; then
|
||||
printf '[i] "%.100s"\n' "$line" >&2
|
||||
fi
|
||||
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
|
||||
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai gemini ask "what happened"\n' "$age" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory ───────────────────────────────────
|
||||
# History lives as a Gemini "contents" JSON document per session name under
|
||||
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat always run in a
|
||||
# session ('default' unless --session names another).
|
||||
session_file() {
|
||||
local name="${1:-$SESSION}"
|
||||
name="${name//[^A-Za-z0-9_-]/_}"
|
||||
printf '%s/%s.json' "$SESSION_DIR" "$name"
|
||||
}
|
||||
|
||||
session_load() {
|
||||
[ -n "$SESSION" ] || { printf '{"contents":[]}'; return 0; }
|
||||
local f
|
||||
f="$(session_file)"
|
||||
if [ -s "$f" ] && jq -e '.contents' "$f" >/dev/null 2>&1; then
|
||||
cat "$f"
|
||||
else
|
||||
printf '{"contents":[]}'
|
||||
fi
|
||||
}
|
||||
|
||||
session_save() {
|
||||
[ -n "$SESSION" ] || return 0
|
||||
local f tmp
|
||||
f="$(session_file)"
|
||||
mkdir -p "$SESSION_DIR"
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$1" >"$tmp"
|
||||
mv "$tmp" "$f"
|
||||
chmod 600 "$f"
|
||||
}
|
||||
|
||||
# Append a turn and prune to the last MAX_SESSION_TURNS entries. stdout = JSON.
|
||||
session_push() {
|
||||
local contents="$1" role="$2" text="$3"
|
||||
printf '%s' "$contents" | jq -c --arg r "$role" --arg t "$text" \
|
||||
'.contents += [{role:$r, parts:[{text:$t}]}] | .contents |= .[-'"$MAX_SESSION_TURNS"':]'
|
||||
}
|
||||
|
||||
# One generateContent call. $1 = model, $2 = contents JSON, $3 = optional
|
||||
# system instruction (added as systemInstruction, not stored in the session).
|
||||
# stdout = the answer text on success; an error message on failure (exit 1).
|
||||
gemini_generate() {
|
||||
local model="$1" contents="$2" system="${3:-}" body
|
||||
body="$contents"
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$contents" | jq -c --arg s "$system" \
|
||||
'. + {systemInstruction:{role:"system",parts:[{text:$s}]}}')"
|
||||
fi
|
||||
local resp code body_out errmsg
|
||||
resp="$(curl -sS -m 60 -X POST "${API}/models/${model}:generateContent" \
|
||||
-H "x-goog-api-key: ${AI_GEMINI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '[.candidates[0].content.parts[]?.text] | join("")'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
# On a tty: one blank line separates the answer from the prompt line above,
|
||||
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
|
||||
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
|
||||
# markers kept. glow(1) is used opportunistically when installed. The answer
|
||||
# ends with exactly one trailing newline.
|
||||
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
|
||||
# (nothing added: single trailing newline only).
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
if [ ! -t 1 ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
# The awk program lives in a variable: its backtick regexes would be
|
||||
# parsed as command substitution inside $( … ). $() strips any
|
||||
# renderer-added trailing newlines; the final printf re-adds exactly
|
||||
# one, plus the leading blank separator line.
|
||||
prog='
|
||||
BEGIN {
|
||||
e = sprintf("%c", 27)
|
||||
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
|
||||
YEL = e "[33m"; CYA = e "[1;36m"
|
||||
RULE = ""
|
||||
for (i = 0; i < 60; i++) RULE = RULE "─"
|
||||
RULE = DIM RULE R
|
||||
}
|
||||
/^```/ { fence = !fence; next }
|
||||
fence { printf "%s %s%s\n", DIM, $0, R; next }
|
||||
/^#{1,4} / {
|
||||
sub(/^#{1,4} +/, "")
|
||||
printf "%s%s%s\n", CYA, $0, R
|
||||
next
|
||||
}
|
||||
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
|
||||
{
|
||||
line = $0
|
||||
out = ""; rest = line
|
||||
while (match(rest, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
|
||||
out = out substr(rest, 1, RSTART - 1) YEL \
|
||||
substr(rest, RSTART + 1, RLENGTH - 2) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /\*\*[^*]+\*\*/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /__[^_]+__/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print out rest
|
||||
}
|
||||
'
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
rendered="$(printf '%s\n' "$text" | glow -)"
|
||||
else
|
||||
rendered="$(printf '%s\n' "$text" | awk "$prog")"
|
||||
fi
|
||||
printf '\n%s\n' "$rendered"
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
# One compact clause so default answers fit the actual box. Collected
|
||||
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
|
||||
# uname(1) fill any gaps. Every source is optional and failures are
|
||||
# ignored — with nothing resolvable the clause is omitted entirely.
|
||||
|
||||
mc_clean() {
|
||||
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
|
||||
# CR…), collapse all whitespace runs to single spaces, trim both ends.
|
||||
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
|
||||
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
|
||||
-e 's/[[:space:]][[:space:]]*/ /g' \
|
||||
| tr -d '\000-\010\013-\037\177' \
|
||||
| sed -e 's/^ //; s/ $//'
|
||||
}
|
||||
|
||||
machine_context() {
|
||||
local raw line key val h="" o="" k="" a="" part out=""
|
||||
# Preferred single source: one hostnamectl status call.
|
||||
if command -v hostnamectl >/dev/null 2>&1; then
|
||||
raw="$(hostnamectl status 2>/dev/null || true)"
|
||||
while IFS= read -r line; do
|
||||
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
|
||||
val="${line#*:}"
|
||||
case "$key" in
|
||||
Statichostname|Transienthostname|Hostname)
|
||||
[ -z "$h" ] && h="$val" ;;
|
||||
OperatingSystem)
|
||||
[ -z "$o" ] && o="$val" ;;
|
||||
Kernel)
|
||||
[ -z "$k" ] && k="$val" ;;
|
||||
Architecture)
|
||||
[ -z "$a" ] && a="$val" ;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
fi
|
||||
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
|
||||
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
|
||||
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
|
||||
o="$(
|
||||
. "$OS_RELEASE_FILE" 2>/dev/null || true
|
||||
if [ -n "${PRETTY_NAME:-}" ]; then
|
||||
printf '%s' "$PRETTY_NAME"
|
||||
elif [ -n "${NAME:-}" ]; then
|
||||
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
|
||||
fi
|
||||
)"
|
||||
fi
|
||||
# Fallback/complement for kernel + arch: uname(1).
|
||||
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
|
||||
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
|
||||
h="$(printf '%s' "$h" | mc_clean)"
|
||||
o="$(printf '%s' "$o" | mc_clean)"
|
||||
k="$(printf '%s' "$k" | mc_clean)"
|
||||
a="$(printf '%s' "$a" | mc_clean)"
|
||||
# Normalize to the bare release: both hostnamectl and uname -sr report
|
||||
# "Linux <rel>".
|
||||
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
|
||||
local out=""
|
||||
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
|
||||
[ -n "$part" ] || continue
|
||||
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
|
||||
done
|
||||
[ -n "$out" ] || return 0
|
||||
printf 'Machine context (answers must fit this box): %s.' "$out"
|
||||
}
|
||||
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai gemini capture <command> [args...]"
|
||||
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
|
||||
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
|
||||
return $rc
|
||||
}
|
||||
|
||||
cmd_ask() {
|
||||
local prompt="" contents out system ctx mc
|
||||
if [ $# -gt 0 ]; then
|
||||
prompt="$*"
|
||||
elif [ ! -t 0 ]; then
|
||||
prompt="$(cat)"
|
||||
fi
|
||||
[ -n "$prompt" ] || err "No prompt given — usage: pos ai gemini ask \"<prompt>\""
|
||||
# --last: append the most recent logged pos command output AFTER the
|
||||
# question, so the model diagnoses the real failure.
|
||||
if [ "$LAST_MODE" -eq 1 ]; then
|
||||
local log_file="" pos_log="" captured_log=""
|
||||
pos_log="$(newest_pos_log 2>/dev/null)" || true
|
||||
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
|
||||
# Use whichever source is newer (auto-capture beats stale pos logs)
|
||||
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
|
||||
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
|
||||
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
|
||||
if [ "$cap_age" -lt "$pos_age" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
elif [ -n "$captured_log" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai gemini capture <cmd>' first, or pipe: cmd 2>&1 | pos ai gemini ask \"what happened\""
|
||||
last_log_annotate "$log_file"
|
||||
ctx="$(last_log_context "$log_file")"
|
||||
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
|
||||
fi
|
||||
require_key
|
||||
# Terse by default: user --system replaces the built-in prompt wholesale;
|
||||
# --full skips everything (built-in text AND machine context). The
|
||||
# default prompt carries a machine-context clause so answers fit this
|
||||
# box; with no detectable facts it is omitted.
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$DEFAULT_SYSTEM_PROMPT$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
contents="$(session_load)"
|
||||
contents="$(session_push "$contents" user "$prompt")"
|
||||
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
contents="$(session_push "$contents" model "$out")"
|
||||
session_save "$contents"
|
||||
render_markdown "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model contents text answer
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
contents="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')"
|
||||
trap 'echo; echo "bye"; exit 0' INT
|
||||
echo "Gemini · ${model} — type a message; q=quit, /reset=clear history"
|
||||
while true; do
|
||||
printf '> '
|
||||
IFS= read -r text || break
|
||||
case "$text" in
|
||||
"" ) continue ;;
|
||||
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
|
||||
/reset)
|
||||
contents='{"contents":[]}'
|
||||
session_save "$contents"
|
||||
echo "[history cleared]"
|
||||
continue ;;
|
||||
esac
|
||||
contents="$(session_push "$contents" user "$text")"
|
||||
if ! answer="$(gemini_generate "$model" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
warn "AI error: $answer"
|
||||
continue
|
||||
fi
|
||||
contents="$(session_push "$contents" model "$answer")"
|
||||
session_save "$contents"
|
||||
# The bare '> ' prompt has no newline: this one closes the prompt
|
||||
# line, and render_markdown's tty-only blank line then shows as the
|
||||
# visible gap before the answer. On a pipe both bytes are preserved.
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
printf '\n\n'
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_sessions() {
|
||||
local action="${1:-list}" name f n
|
||||
case "$action" in
|
||||
list|"")
|
||||
[ -d "$SESSION_DIR" ] || { echo "no sessions"; return 0; }
|
||||
local found=0
|
||||
for f in "$SESSION_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
found=1
|
||||
n="$(jq -r '.contents | length' "$f" 2>/dev/null || echo 0)"
|
||||
printf ' %-32s %s turns\n' "$(basename "$f" .json)" "${n:-0}"
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo "no sessions"
|
||||
;;
|
||||
reset)
|
||||
[ $# -ge 2 ] || err "usage: pos ai gemini sessions reset <name>"
|
||||
name="$2"
|
||||
if rm -f "$(session_file "$name")"; then
|
||||
ok "session '$name' cleared"
|
||||
fi
|
||||
;;
|
||||
*) err "Unknown sessions subcommand '$action' (list | reset <name>)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_models() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for models: $*"
|
||||
local model resp code body m
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
resp="$(curl -sS -m 30 -G "${API}/models" \
|
||||
-H "x-goog-api-key: ${AI_GEMINI_API_KEY}" \
|
||||
--data-urlencode "pageSize=1000" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
fi
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.models[]? | select((.supportedGenerationMethods // []) | index("generateContent")) | .name' | sed 's#^models/##' | sort)"
|
||||
echo "Gemini models (generateContent-capable):"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-32s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-32s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list"; then
|
||||
warn "configured default '$model' is not in the list — set AI_GEMINI_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
cmd=""
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--model)
|
||||
[ $# -ge 2 ] || err "--model needs a value"
|
||||
MODEL_OVERRIDE="$2"; shift 2 ;;
|
||||
--session)
|
||||
[ $# -ge 2 ] || err "--session needs a value"
|
||||
SESSION="$2"; shift 2 ;;
|
||||
--system)
|
||||
[ $# -ge 2 ] || err "--system needs a value"
|
||||
SYSTEM_PROMPT="$2"; shift 2 ;;
|
||||
--full)
|
||||
FULL_MODE=1; shift ;;
|
||||
--last)
|
||||
LAST_MODE=1; shift ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai gemini capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai gemini subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
# POS: ai gemini — Forward to pos ai --provider gemini (backward compat)
|
||||
# POS_SUBCMDS: ask chat models sessions capture
|
||||
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/gemini.sh
|
||||
case "${1:-}" in -h|--help) exec pos ai --provider gemini --help ;; esac
|
||||
exec pos ai --provider gemini "$@"
|
||||
|
||||
+5
-595
@@ -1,597 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai openrouter — Chat with OpenRouter models (ask, capture, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask capture chat models sessions
|
||||
# POS_FLAGS: --model --session --system --full --last
|
||||
# POS_CONFIG: ai-openrouter | ai-openrouter.env | OPENROUTER_API_KEY=secret:API key from openrouter.ai (https://openrouter.ai/settings/keys) | OPENROUTER_MODEL=:Model id (default openrouter/auto)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai-openrouter.env"
|
||||
API="https://openrouter.ai/api/v1"
|
||||
DEFAULT_MODEL="openrouter/auto"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai-openrouter"
|
||||
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
|
||||
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
|
||||
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
|
||||
SESSION="default"
|
||||
SYSTEM_PROMPT=""
|
||||
MAX_SESSION_TURNS=40
|
||||
LAST_LOG_MAX_BYTES=4096
|
||||
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
|
||||
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
|
||||
# (see machine_context) unless --system replaces it or --full drops everything.
|
||||
DEFAULT_SYSTEM_PROMPT="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai openrouter <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
Chat with OpenRouter models via the REST API (openrouter.ai).
|
||||
|
||||
Subcommands:
|
||||
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
|
||||
also be piped in via stdin when no argument is given.
|
||||
Runs inside the persistent 'default' session (prior turns
|
||||
are sent as context); --session <name> picks another.
|
||||
capture <cmd..> Run a command, capture its output for --last, and show it.
|
||||
Each capture overwrites the previous one (latest only).
|
||||
chat Interactive multi-turn conversation (session 'default'
|
||||
unless --session is given).
|
||||
models List available models and flag the configured default.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai-openrouter/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns).
|
||||
--system <text> System instruction sent with every turn (kept out of the
|
||||
session file); replaces the built-in terse ask prompt
|
||||
wholesale, e.g. "Reply like a friendly Telegram chat".
|
||||
--full Skip the built-in terse prompt — long-form answers.
|
||||
--last ask only: attach the most recent pos dispatcher log or
|
||||
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
|
||||
the model can diagnose a real failure. Sources in priority
|
||||
order: (1) newest pos log, (2) captured output from
|
||||
'capture'. Notes on stderr which source was attached and
|
||||
its age; warns when stale (>60 min).
|
||||
-h|--help This help.
|
||||
|
||||
Config: $CONFIG_FILE (edit with 'pos config ai-openrouter')
|
||||
OPENROUTER_API_KEY API key from openrouter.ai (required)
|
||||
OPENROUTER_MODEL Model id (default $DEFAULT_MODEL)
|
||||
|
||||
Notes:
|
||||
ask is terse by default: a built-in system instruction tells the model to
|
||||
lead with the exact commands and keep prose minimal — and to diagnose pasted
|
||||
errors/output with a fix first. That prompt ends with one machine-context
|
||||
line (hostname, distro, kernel, arch detected on this box) so answers fit
|
||||
the actual machine; --system replaces it wholesale, --full drops it all.
|
||||
Every ask/chat lands in a persistent session ('default' unless --session);
|
||||
clear it with 'pos ai openrouter sessions reset default'. On a terminal the
|
||||
answer is rendered as markdown (glow if installed, else a built-in
|
||||
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
|
||||
raw markdown is printed unchanged.
|
||||
|
||||
Examples:
|
||||
pos ai openrouter ask "check disk space on /"
|
||||
pos ai openrouter ask --full "Explain DNS in depth"
|
||||
echo "summarize this log" | pos ai openrouter ask
|
||||
failing-cmd 2>&1 | pos ai openrouter ask how do I fix this
|
||||
pos ai openrouter ask --last "why did that fail?" # attach last output
|
||||
pos ai openrouter capture pip install xyz # capture any command
|
||||
pos ai openrouter ask --last "what happened?" # after capture
|
||||
pos ai openrouter chat
|
||||
pos ai openrouter ask --model anthropic/claude-sonnet-4 "hi"
|
||||
pos ai openrouter ask --system "Reply like a pirate" "explain chmod"
|
||||
pos ai openrouter ask --session work "my name is joe"
|
||||
pos ai openrouter ask --session work "what is my name?" # remembers
|
||||
pos ai openrouter sessions
|
||||
pos ai openrouter sessions reset default # forget default memory
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── ai-openrouter.env loader (same pattern as telegram.env) ────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
}
|
||||
|
||||
require_key() {
|
||||
load_config
|
||||
[ -n "${OPENROUTER_API_KEY:-}" ] || err "No OpenRouter API key — run 'pos config ai-openrouter'"
|
||||
}
|
||||
|
||||
resolve_model() {
|
||||
if [ -n "${MODEL_OVERRIDE:-}" ]; then
|
||||
printf '%s' "$MODEL_OVERRIDE"
|
||||
elif [ -n "${OPENROUTER_MODEL:-}" ]; then
|
||||
printf '%s' "$OPENROUTER_MODEL"
|
||||
else
|
||||
printf '%s' "$DEFAULT_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
|
||||
# (ai-openrouter itself is interactive-logged, so it never creates its own output
|
||||
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
|
||||
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
|
||||
# index, not command output — excluded.
|
||||
newest_pos_log() {
|
||||
[ -d "$DISPATCH_LOG_DIR" ] || return 1
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
|
||||
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
|
||||
return 1
|
||||
}
|
||||
|
||||
# stdout = the context block body for log $1: its END kept (errors live at
|
||||
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
# a byte cut can split a multibyte char — drop invalid sequences when possible
|
||||
if command -v iconv >/dev/null 2>&1; then
|
||||
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
|
||||
fi
|
||||
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
|
||||
printf '[…truncated…]\n%s' "$raw"
|
||||
else
|
||||
printf '%s' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last transparency (stderr-only; stdout stays pure answer) ──
|
||||
# Seconds → human age: just now / Nm / Nh / Nd.
|
||||
human_age() {
|
||||
local s="$1"
|
||||
[ "$s" -lt 0 ] && s=0
|
||||
if [ "$s" -lt 60 ]; then printf 'just now'
|
||||
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
|
||||
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
|
||||
else printf '%sd' "$((s / 86400))"
|
||||
fi
|
||||
}
|
||||
|
||||
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
|
||||
# so a misread of an ancient log as the current failure is visible before
|
||||
# the model answers. $1 = log file (as recorded by last_log_context).
|
||||
last_log_annotate() {
|
||||
local f="$1" age_s age line
|
||||
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
|
||||
[ "$age_s" -lt 0 ] && age_s=0
|
||||
age="$(human_age "$age_s")"
|
||||
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
|
||||
# preview: first meaningful line of the log (blank lines skipped)
|
||||
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
|
||||
if [ -n "$line" ]; then
|
||||
printf '[i] "%.100s"\n' "$line" >&2
|
||||
fi
|
||||
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
|
||||
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai openrouter ask "what happened"\n' "$age" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory ───────────────────────────────────
|
||||
# History lives as an OpenAI-style "messages" JSON document per session name under
|
||||
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat always run in a
|
||||
# session ('default' unless --session names another).
|
||||
session_file() {
|
||||
local name="${1:-$SESSION}"
|
||||
name="${name//[^A-Za-z0-9_-]/_}"
|
||||
printf '%s/%s.json' "$SESSION_DIR" "$name"
|
||||
}
|
||||
|
||||
session_load() {
|
||||
[ -n "$SESSION" ] || { printf '{"messages":[]}'; return 0; }
|
||||
local f
|
||||
f="$(session_file)"
|
||||
if [ -s "$f" ] && jq -e '.messages' "$f" >/dev/null 2>&1; then
|
||||
cat "$f"
|
||||
else
|
||||
printf '{"messages":[]}'
|
||||
fi
|
||||
}
|
||||
|
||||
session_save() {
|
||||
[ -n "$SESSION" ] || return 0
|
||||
local f tmp
|
||||
f="$(session_file)"
|
||||
mkdir -p "$SESSION_DIR"
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$1" >"$tmp"
|
||||
mv "$tmp" "$f"
|
||||
chmod 600 "$f"
|
||||
}
|
||||
|
||||
# Append a turn and prune to the last MAX_SESSION_TURNS entries. stdout = JSON.
|
||||
session_push() {
|
||||
local messages="$1" role="$2" text="$3"
|
||||
printf '%s' "$messages" | jq -c --arg r "$role" --arg t "$text" \
|
||||
'.messages += [{role:$r, content:$t}] | .messages |= .[-'"$MAX_SESSION_TURNS"':]'
|
||||
}
|
||||
|
||||
# One OpenRouter chat completion call. $1 = model, $2 = messages JSON, $3 = optional
|
||||
# system instruction (prepended as a system message, not stored in the session).
|
||||
# stdout = the answer text on success; an error message on failure (exit 1).
|
||||
openrouter_generate() {
|
||||
local model="$1" messages_json="$2" system="${3:-}" body resp code body_out errmsg
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$messages_json" | jq -c --arg s "$system" \
|
||||
'[{role:"system",content:$s}] + .messages')"
|
||||
else
|
||||
body="$(printf '%s' "$messages_json" | jq -c '.messages')"
|
||||
fi
|
||||
body="$(printf '%s' "$body" | jq -nc --arg m "$model" --argjson msgs "$body" \
|
||||
'{model:$m, messages:$msgs}')"
|
||||
resp="$(curl -sS -m 60 -X POST "${API}/chat/completions" \
|
||||
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "HTTP-Referer: https://github.com/admin/Linux_post_install" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '.choices[0].message.content // ""'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
# On a tty: one blank line separates the answer from the prompt line above,
|
||||
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
|
||||
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
|
||||
# markers kept. glow(1) is used opportunistically when installed. The answer
|
||||
# ends with exactly one trailing newline.
|
||||
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
|
||||
# (nothing added: single trailing newline only).
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
if [ ! -t 1 ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
# The awk program lives in a variable: its backtick regexes would be
|
||||
# parsed as command substitution inside $( … ). $() strips any
|
||||
# renderer-added trailing newlines; the final printf re-adds exactly
|
||||
# one, plus the leading blank separator line.
|
||||
prog='
|
||||
BEGIN {
|
||||
e = sprintf("%c", 27)
|
||||
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
|
||||
YEL = e "[33m"; CYA = e "[1;36m"
|
||||
RULE = ""
|
||||
for (i = 0; i < 60; i++) RULE = RULE "─"
|
||||
RULE = DIM RULE R
|
||||
}
|
||||
/^```/ { fence = !fence; next }
|
||||
fence { printf "%s %s%s\n", DIM, $0, R; next }
|
||||
/^#{1,4} / {
|
||||
sub(/^#{1,4} +/, "")
|
||||
printf "%s%s%s\n", CYA, $0, R
|
||||
next
|
||||
}
|
||||
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
|
||||
{
|
||||
line = $0
|
||||
out = ""; rest = line
|
||||
while (match(rest, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
|
||||
out = out substr(rest, 1, RSTART - 1) YEL \
|
||||
substr(rest, RSTART + 1, RLENGTH - 2) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /\*\*[^*]+\*\*/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /__[^_]+__/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print out rest
|
||||
}
|
||||
'
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
rendered="$(printf '%s\n' "$text" | glow -)"
|
||||
else
|
||||
rendered="$(printf '%s\n' "$text" | awk "$prog")"
|
||||
fi
|
||||
printf '\n%s\n' "$rendered"
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
# One compact clause so default answers fit the actual box. Collected
|
||||
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
|
||||
# uname(1) fill any gaps. Every source is optional and failures are
|
||||
# ignored — with nothing resolvable the clause is omitted entirely.
|
||||
|
||||
mc_clean() {
|
||||
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
|
||||
# CR…), collapse all whitespace runs to single spaces, trim both ends.
|
||||
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
|
||||
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
|
||||
-e 's/[[:space:]][[:space:]]*/ /g' \
|
||||
| tr -d '\000-\010\013-\037\177' \
|
||||
| sed -e 's/^ //; s/ $//'
|
||||
}
|
||||
|
||||
machine_context() {
|
||||
local raw line key val h="" o="" k="" a="" part out=""
|
||||
# Preferred single source: one hostnamectl status call.
|
||||
if command -v hostnamectl >/dev/null 2>&1; then
|
||||
raw="$(hostnamectl status 2>/dev/null || true)"
|
||||
while IFS= read -r line; do
|
||||
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
|
||||
val="${line#*:}"
|
||||
case "$key" in
|
||||
Statichostname|Transienthostname|Hostname)
|
||||
[ -z "$h" ] && h="$val" ;;
|
||||
OperatingSystem)
|
||||
[ -z "$o" ] && o="$val" ;;
|
||||
Kernel)
|
||||
[ -z "$k" ] && k="$val" ;;
|
||||
Architecture)
|
||||
[ -z "$a" ] && a="$val" ;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
fi
|
||||
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
|
||||
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
|
||||
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
|
||||
o="$(
|
||||
. "$OS_RELEASE_FILE" 2>/dev/null || true
|
||||
if [ -n "${PRETTY_NAME:-}" ]; then
|
||||
printf '%s' "$PRETTY_NAME"
|
||||
elif [ -n "${NAME:-}" ]; then
|
||||
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
|
||||
fi
|
||||
)"
|
||||
fi
|
||||
# Fallback/complement for kernel + arch: uname(1).
|
||||
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
|
||||
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
|
||||
h="$(printf '%s' "$h" | mc_clean)"
|
||||
o="$(printf '%s' "$o" | mc_clean)"
|
||||
k="$(printf '%s' "$k" | mc_clean)"
|
||||
a="$(printf '%s' "$a" | mc_clean)"
|
||||
# Normalize to the bare release: both hostnamectl and uname -sr report
|
||||
# "Linux <rel>".
|
||||
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
|
||||
local out=""
|
||||
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
|
||||
[ -n "$part" ] || continue
|
||||
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
|
||||
done
|
||||
[ -n "$out" ] || return 0
|
||||
printf 'Machine context (answers must fit this box): %s.' "$out"
|
||||
}
|
||||
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai openrouter capture <command> [args...]"
|
||||
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
|
||||
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
|
||||
return $rc
|
||||
}
|
||||
|
||||
cmd_ask() {
|
||||
local prompt="" messages out system ctx mc
|
||||
if [ $# -gt 0 ]; then
|
||||
prompt="$*"
|
||||
elif [ ! -t 0 ]; then
|
||||
prompt="$(cat)"
|
||||
fi
|
||||
[ -n "$prompt" ] || err "No prompt given — usage: pos ai openrouter ask \"<prompt>\""
|
||||
# --last: append the most recent logged pos command output AFTER the
|
||||
# question, so the model diagnoses the real failure.
|
||||
if [ "$LAST_MODE" -eq 1 ]; then
|
||||
local log_file="" pos_log="" captured_log=""
|
||||
pos_log="$(newest_pos_log 2>/dev/null)" || true
|
||||
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
|
||||
# Use whichever source is newer (auto-capture beats stale pos logs)
|
||||
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
|
||||
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
|
||||
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
|
||||
if [ "$cap_age" -lt "$pos_age" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
elif [ -n "$captured_log" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai openrouter capture <cmd>' first, or pipe: cmd 2>&1 | pos ai openrouter ask \"what happened\""
|
||||
last_log_annotate "$log_file"
|
||||
ctx="$(last_log_context "$log_file")"
|
||||
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
|
||||
fi
|
||||
require_key
|
||||
# Terse by default: user --system replaces the built-in prompt wholesale;
|
||||
# --full skips everything (built-in text AND machine context). The
|
||||
# default prompt carries a machine-context clause so answers fit this
|
||||
# box; with no detectable facts it is omitted.
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$DEFAULT_SYSTEM_PROMPT$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
messages="$(session_load)"
|
||||
messages="$(session_push "$messages" user "$prompt")"
|
||||
if ! out="$(openrouter_generate "$(resolve_model)" "$messages" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$out")"
|
||||
session_save "$messages"
|
||||
render_markdown "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model messages text answer
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
messages="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$messages" | jq -r '.messages | length')"
|
||||
trap 'echo; echo "bye"; exit 0' INT
|
||||
echo "OpenRouter · ${model} — type a message; q=quit, /reset=clear history"
|
||||
while true; do
|
||||
printf '> '
|
||||
IFS= read -r text || break
|
||||
case "$text" in
|
||||
"" ) continue ;;
|
||||
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
|
||||
/reset)
|
||||
messages='{"messages":[]}'
|
||||
session_save "$messages"
|
||||
echo "[history cleared]"
|
||||
continue ;;
|
||||
esac
|
||||
messages="$(session_push "$messages" user "$text")"
|
||||
if ! answer="$(openrouter_generate "$model" "$messages" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
warn "AI error: $answer"
|
||||
continue
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$answer")"
|
||||
session_save "$messages"
|
||||
# The bare '> ' prompt has no newline: this one closes the prompt
|
||||
# line, and render_markdown's tty-only blank line then shows as the
|
||||
# visible gap before the answer. On a pipe both bytes are preserved.
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
printf '\n\n'
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_sessions() {
|
||||
local action="${1:-list}" name f n
|
||||
case "$action" in
|
||||
list|"")
|
||||
[ -d "$SESSION_DIR" ] || { echo "no sessions"; return 0; }
|
||||
local found=0
|
||||
for f in "$SESSION_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
found=1
|
||||
n="$(jq -r '.messages | length' "$f" 2>/dev/null || echo 0)"
|
||||
printf ' %-32s %s turns\n' "$(basename "$f" .json)" "${n:-0}"
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo "no sessions"
|
||||
;;
|
||||
reset)
|
||||
[ $# -ge 2 ] || err "usage: pos ai openrouter sessions reset <name>"
|
||||
name="$2"
|
||||
if rm -f "$(session_file "$name")"; then
|
||||
ok "session '$name' cleared"
|
||||
fi
|
||||
;;
|
||||
*) err "Unknown sessions subcommand '$action' (list | reset <name>)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_models() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for models: $*"
|
||||
local model resp code body m
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
resp="$(curl -sS -m 30 "${API}/models" \
|
||||
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
fi
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.data[]?.id' | sort)"
|
||||
echo "OpenRouter models:"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-48s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-48s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list"; then
|
||||
warn "configured default '$model' is not in the list — set OPENROUTER_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
cmd=""
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--model)
|
||||
[ $# -ge 2 ] || err "--model needs a value"
|
||||
MODEL_OVERRIDE="$2"; shift 2 ;;
|
||||
--session)
|
||||
[ $# -ge 2 ] || err "--session needs a value"
|
||||
SESSION="$2"; shift 2 ;;
|
||||
--system)
|
||||
[ $# -ge 2 ] || err "--system needs a value"
|
||||
SYSTEM_PROMPT="$2"; shift 2 ;;
|
||||
--full)
|
||||
FULL_MODE=1; shift ;;
|
||||
--last)
|
||||
LAST_MODE=1; shift ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai openrouter capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai openrouter subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
# POS: ai openrouter — Forward to pos ai --provider openrouter (backward compat)
|
||||
# POS_SUBCMDS: ask chat sessions capture
|
||||
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/openrouter.sh
|
||||
case "${1:-}" in -h|--help) exec pos ai --provider openrouter --help ;; esac
|
||||
exec pos ai --provider openrouter "$@"
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# Install: source this file in ~/.bashrc or place in /etc/bash_completion.d/
|
||||
# GEN:START posflags
|
||||
declare -A _pos_flags
|
||||
_pos_flags[ai-gemini]="--model --session --system --full --last"
|
||||
_pos_flags[ai-openrouter]="--model --session --system --full --last"
|
||||
_pos_flags[communication-matrix-listener]="--enable --disable --status --run"
|
||||
_pos_flags[communication-telegram-listener]="--enable --disable --status --sync-commands --run"
|
||||
_pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-preview --token --chat-id --markdown"
|
||||
@@ -21,12 +19,13 @@ _pos_flags[network-hotspot]="--foreground"
|
||||
_pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version menu"
|
||||
_pos_flags[system-backup]="--service --no-encrypt"
|
||||
_pos_flags[system-schedule]="--dry-run"
|
||||
_pos_flags[ai]="--provider --model --session --system --full --last"
|
||||
_pos_flags[tree]="--depth"
|
||||
# GEN:END posflags
|
||||
# GEN:START possubcmds
|
||||
declare -A _pos_subcmds
|
||||
_pos_subcmds[ai-gemini]="ask capture chat models sessions"
|
||||
_pos_subcmds[ai-openrouter]="ask capture chat models sessions"
|
||||
_pos_subcmds[ai-gemini]="ask chat models sessions capture"
|
||||
_pos_subcmds[ai-openrouter]="ask chat sessions capture"
|
||||
_pos_subcmds[communication-matrix-sender]="send test login"
|
||||
_pos_subcmds[communication-scrcpy]="devices record tcpip connect push pull screenshot info"
|
||||
_pos_subcmds[communication-telegram-sender]="send test"
|
||||
@@ -41,9 +40,10 @@ _pos_subcmds[share-smb-client]="mount unmount list persist unpersist menu"
|
||||
_pos_subcmds[share-smb-server]="status share unshare list adduser deluser reload enable disable menu"
|
||||
_pos_subcmds[system-backup]="menu"
|
||||
_pos_subcmds[system-schedule]="run list config enable disable status migrate menu"
|
||||
_pos_subcmds[ai]="ask chat sessions capture models providers gemini openrouter"
|
||||
# GEN:END possubcmds
|
||||
# GEN:START posconfigscopes
|
||||
declare -a _pos_config_scopes=(ai ai-openrouter compose entertainment matrix notify scrcpy system telegram ytsync)
|
||||
declare -a _pos_config_scopes=(ai compose entertainment matrix notify scrcpy system telegram ytsync)
|
||||
# GEN:END posconfigscopes
|
||||
|
||||
_pos() {
|
||||
|
||||
+11
-4
@@ -1,8 +1,15 @@
|
||||
# ~/.config/linux_post_install/ai.env — Google Gemini config for 'pos ai gemini'
|
||||
# ~/.config/linux_post_install/ai.env — AI provider config for 'pos ai'
|
||||
# Copied to ~/.config/linux_post_install/ai.env on install (no clobber).
|
||||
# Edit with: pos config ai
|
||||
#
|
||||
# Syntax:
|
||||
# AI_GEMINI_API_KEY=<key> # required — API key from aistudio.google.com
|
||||
# # (never commit this to the repo)
|
||||
# AI_GEMINI_MODEL=<model> # optional — model id, default gemini-2.5-flash
|
||||
# AI_PROVIDER=gemini # provider: gemini|openrouter (default gemini)
|
||||
# AI_API_KEY=<key> # API key for the active provider (secret)
|
||||
# AI_MODEL=<model> # model id (default depends on provider)
|
||||
# AI_SYSTEM_PROMPT=<prompt> # custom system prompt (overrides built-in; empty to reset)
|
||||
#
|
||||
# Legacy provider-specific keys (still supported as fallback):
|
||||
# AI_GEMINI_API_KEY=<key> # Gemini API key (used when AI_API_KEY is empty)
|
||||
# AI_GEMINI_MODEL=<model> # Gemini model id (used when AI_MODEL is empty)
|
||||
# OPENROUTER_API_KEY=<key> # OpenRouter API key (used when AI_API_KEY is empty)
|
||||
# OPENROUTER_MODEL=<model> # OpenRouter model id (used when AI_MODEL is empty)
|
||||
|
||||
+13
@@ -147,6 +147,19 @@ if should_run 2 scripts; then
|
||||
done
|
||||
log "libs -> /usr/local/bin (644): ${lib_names% }"
|
||||
|
||||
# ── AI provider adapters ────────────────────────────────────
|
||||
# Installed into /usr/local/bin/ai-providers/ for pos-ai.
|
||||
ap_count=0
|
||||
ap_names=""
|
||||
run sudo mkdir -p /usr/local/bin/ai-providers
|
||||
for apf in lib/ai-providers/*.sh; do
|
||||
[ -f "$apf" ] || continue
|
||||
run sudo install -m 644 "$apf" "/usr/local/bin/ai-providers/"
|
||||
ap_count=$((ap_count + 1))
|
||||
ap_names+="$(basename "$apf") "
|
||||
done
|
||||
[ "$ap_count" -gt 0 ] && log "ai-providers -> /usr/local/bin/ai-providers (644): ${ap_names% }"
|
||||
|
||||
# ── Entertainment plugins ────────────────────────────────
|
||||
# Installed into /usr/local/bin so the repo can be deleted afterwards.
|
||||
pcount=0
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gemini provider adapter for pos-ai
|
||||
# Provider-specific: API call, auth, response parsing, models list
|
||||
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
|
||||
|
||||
provider_name() { printf 'Google Gemini'; }
|
||||
provider_default_model() { printf 'gemini-2.5-flash'; }
|
||||
|
||||
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
|
||||
provider_generate() {
|
||||
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
|
||||
# Convert OpenAI messages format to Gemini contents format
|
||||
body="$(printf '%s' "$messages" | jq -c '{
|
||||
contents: [.messages[]? | {role: (.role | gsub("assistant";"model")), parts: [{text: .content}]}]
|
||||
}')"
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$body" | jq -c --arg s "$system" \
|
||||
'. + {systemInstruction:{role:"system",parts:[{text:$s}]}}')"
|
||||
fi
|
||||
resp="$(curl -sS -m 60 -X POST "https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent" \
|
||||
-H "x-goog-api-key: ${AI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '[.candidates[0].content.parts[]?.text] | join("")'
|
||||
}
|
||||
|
||||
# $1=current default model → stdout=formatted model list
|
||||
provider_models_list() {
|
||||
local model="$1" resp code body m
|
||||
resp="$(curl -sS -m 30 -G "https://generativelanguage.googleapis.com/v1beta/models" \
|
||||
-H "x-goog-api-key: ${AI_API_KEY}" \
|
||||
--data-urlencode "pageSize=1000" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.models[]? | select((.supportedGenerationMethods // []) | index("generateContent")) | .name' | sed 's#^models/##' | sort)"
|
||||
echo "Gemini models (generateContent-capable):"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-32s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-32s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list" 2>/dev/null; then
|
||||
warn "configured default '$model' is not in the list — set AI_MODEL or AI_GEMINI_MODEL"
|
||||
fi
|
||||
}
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenRouter provider adapter for pos-ai
|
||||
# Provider-specific: API call, auth, response parsing, models list
|
||||
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
|
||||
|
||||
provider_name() { printf 'OpenRouter'; }
|
||||
provider_default_model() { printf 'openrouter/auto'; }
|
||||
|
||||
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
|
||||
provider_generate() {
|
||||
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$messages" | jq -c --arg s "$system" \
|
||||
'[{role:"system",content:$s}] + .messages')"
|
||||
else
|
||||
body="$(printf '%s' "$messages" | jq -c '.messages')"
|
||||
fi
|
||||
body="$(printf '%s' "$body" | jq -nc --arg m "$model" --argjson msgs "$body" \
|
||||
'{model:$m, messages:$msgs}')"
|
||||
resp="$(curl -sS -m 60 -X POST "https://openrouter.ai/api/v1/chat/completions" \
|
||||
-H "Authorization: Bearer ${AI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "HTTP-Referer: https://github.com/admin/Linux_post_install" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '.choices[0].message.content // ""'
|
||||
}
|
||||
|
||||
# $1=current default model → stdout=formatted model list
|
||||
provider_models_list() {
|
||||
local model="$1" resp code body m
|
||||
resp="$(curl -sS -m 30 "https://openrouter.ai/api/v1/models" \
|
||||
-H "Authorization: Bearer ${AI_API_KEY}" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.data[]?.id' | sort)"
|
||||
echo "OpenRouter models:"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-48s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-48s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list" 2>/dev/null; then
|
||||
warn "configured default '$model' is not in the list — set AI_MODEL or OPENROUTER_MODEL"
|
||||
fi
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# After sourcing, every command's stdout+stderr is silently tee'd to
|
||||
# ~/.local/share/linux_post_install/last_cmd_output (truncated at 1 MB).
|
||||
# Then pos ai gemini ask --last / pos ai openrouter ask --last will
|
||||
# Then pos ai ask --last (or pos ai --provider openrouter ask --last) will
|
||||
# pick it up automatically — no 'capture' subcommand needed.
|
||||
# To disable: unset __POS_CAPTURE_ACTIVE
|
||||
|
||||
|
||||
Reference in New Issue
Block a user