From 476173ba83ea970a971cd883ba26edda0449ceaf Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 25 Aug 2026 08:39:55 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20ai=20=E2=80=94=20gemini=20terse+render+?= =?UTF-8?q?last+session+machine,=20openrouter=20new=20tool,=20capture=20an?= =?UTF-8?q?y=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemini enhancements: - built-in terse system prompt with troubleshooting clause + machine context - markdown→terminal rendering (glow opportunistic + zero-dep awk fallback) - --last: pos logs + captured output fallback, staleness warning, stderr annotations - session default always on; --session override; answer separation on tty - --full flag, --system wholesale override openrouter (new tool): - cloned from gemini, adapted for OpenAI-compatible REST API - Bearer auth, messages array, choices[0].message.content parsing - config: pos config ai-openrouter → OPENROUTER_API_KEY/MODEL - default model: openrouter/auto (auto-picks best model) - all features: ask, chat, sessions, --last, capture capture subcommand (both tools): - runs any command, tees output to last_cmd_output for --last - --last fallback: pos logs (priority) → last_cmd_output (secondary) shell hook (optional): - lib/pos-ai-hook.sh: sourceable .bashrc snippet for auto-capture - exec > >(tee ...) with 1 MB truncation --- DOC/howto/ai.md | 134 +++++++++- bin/pos-ai-gemini | 367 ++++++++++++++++++++++---- bin/pos-ai-openrouter | 587 ++++++++++++++++++++++++++++++++++++++++++ lib/pos-ai-hook.sh | 32 +++ 4 files changed, 1061 insertions(+), 59 deletions(-) create mode 100755 bin/pos-ai-openrouter create mode 100644 lib/pos-ai-hook.sh diff --git a/DOC/howto/ai.md b/DOC/howto/ai.md index bcdaa6a..3c4010d 100644 --- a/DOC/howto/ai.md +++ b/DOC/howto/ai.md @@ -1,18 +1,54 @@ # How-To: `pos ai` -Chat with Google Gemini — from the terminal and through the Telegram bot. -Tools: `gemini` (`ask`, `chat`, `models`). +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 | What it does | |------|--------------| -| `pos ai gemini ask ""` | One-shot answer to stdout (scriptable) | -| `pos ai gemini ask --session "…"` | Same, but remembers prior turns | -| `pos ai gemini chat` | Interactive multi-turn conversation | +| `pos ai gemini ask ""` | 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 ` | Run any command, capture its output for `--last`, and show it on screen | +| `pos ai gemini ask --session "…"` | 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/clear persistent sessions (`reset `) | +| `pos ai gemini sessions` | List persistent sessions / clear one (`reset `, e.g. `reset default`) | -Shared flags: `--model ` overrides the model; `--system ""` adds a -system instruction to every turn (kept out of the session file). +Shared flags: `--model ` overrides the model; `--system ""` 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`. + +Every `ask`/`chat` lands in a persistent session file under +`~/.local/share/linux_post_install/ai/.json` (capped at 40 turns). +Terminal work accumulates in `default`; clear it with +`pos ai gemini sessions reset default`. + +--- + +## Terse by default, rendered on screen + +`ask` prepends a built-in system instruction telling the model to work like a +CLI assistant: lead with the exact commands, one-line explanations, no essays — +and when the message is a "how do I install/update/solve/edit X" question or +pastes an error/command output, diagnose it and lead with the fix command(s). +That prompt ends with one machine-context line (hostname, distro, kernel and +architecture detected on this box), so answers match the actual machine; +`--system ""` swaps it wholesale; `--full` drops it for long-form output. +`chat` keeps its neutral behavior (only `--system` applies). + +On a terminal, answers are rendered as markdown, separated from your prompt +line by one blank line: fenced code blocks stay +monospace (indented + dimmed), inline `` `code` `` turns yellow, `**bold**` +turns bold, headers become bold cyan, `---` becomes a thin rule. If `glow` is +installed it is used automatically; otherwise a small built-in renderer kicks +in — no extra dependency either way. When stdout is **not** a tty (pipes, +scripts, cron, the Telegram/Matrix bridges) the raw markdown bytes are printed +exactly as before (no added blank lines), so scripting stays byte-stable. --- @@ -37,6 +73,47 @@ system instruction to every turn (kept out of the session file). `ai.env` lives at `~/.config/linux_post_install/ai.env` (chmod 600); `pos config ai` is the only place the key is written. The key is never printed by `pos`. +## OpenRouter — many providers, one key + +[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. + +1. Get an API key from https://openrouter.ai/settings/keys. + +2. Configure it: + + ```bash + pos config ai-openrouter # enter OPENROUTER_API_KEY + ``` + +3. Test: + + ```bash + pos ai openrouter ask "hi" + ``` + +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" +``` + +Sessions are stored separately from Gemini's: + +``` +~/.local/share/linux_post_install/ai-openrouter/.json +``` + +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. + ## From the Telegram bot Once `pos ai gemini ask` works, any non-command message starting with `ai ` is @@ -57,7 +134,8 @@ you: ai --model gemini-2.5-flash explain a Raft consensus log ### Telegram memory & formatting -Each chat has its own persistent session (`telegram-`), so the model +Each chat has its own persistent session (`telegram-` — independent +of your terminal's `default` session), so the model remembers the conversation; `ai /reset` clears it. The listener passes a system prompt telling the model it is answering in a Telegram chat — so it uses emojis and stays lively — and strips markdown (`**x**`, backticks, `#`, links…) from @@ -73,23 +151,53 @@ 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 ` 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)"` -- **Pipe into it:** `echo "fix this: $(cat error.log)" | pos ai gemini ask` - **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` - **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" ``` +## Capturing any command's output for --last + +By default, `--last` reads from pos dispatcher logs (only pos commands). To analyze +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" +``` +The `capture` subcommand runs the command, shows its output on screen, and saves it +for `--last`. Each `capture` overwrites the previous one (latest only). + +**Option B — automatic capture (shell hook):** +```bash +# Add to ~/.bashrc: +source /usr/local/bin/pos-ai-hook.sh +``` +After sourcing, every command's output is silently captured. Then just run any +command and `--last` picks it up automatically. Captures up to 1 MB (oldest +truncated). To disable: `unset __POS_CAPTURE_ACTIVE`. + ## How it works -- `ask` POSTs `contents:[{role:user, parts:[{text:"…"}]}]` to +- `ask` POSTs `contents:[…]` (prior turns of the active session plus the new + user turn) to `https://generativelanguage.googleapis.com/v1beta/models/:generateContent` with the key in the `x-goog-api-key` header, and prints `.candidates[0].content.parts[].text` — nothing else. -- `chat` keeps the whole conversation in memory as a growing `contents[]` array, - so later turns have earlier context. `/reset` drops it. +- 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[]` + 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 non-zero — so scripts can rely on `ask` failing loudly. diff --git a/bin/pos-ai-gemini b/bin/pos-ai-gemini index 91b0b4b..8ad34d8 100755 --- a/bin/pos-ai-gemini +++ b/bin/pos-ai-gemini @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# POS: ai gemini — Chat with Google Gemini (ask, chat, models, sessions) -# POS_SUBCMDS: ask chat models sessions -# POS_FLAGS: --model --session --system +# 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" @@ -11,50 +11,86 @@ 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" -SESSION="" +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 < [--model ] [--session ] [--system ] +Usage: pos ai gemini [--model ] [--session ] [--system ] [--full] [--last] Chat with Google Gemini via the REST API (generativelanguage.googleapis.com). Subcommands: - ask "" One-shot answer; prints ONLY the answer text to stdout - (pipe/script/Telegram-friendly). The prompt may also be - piped in via stdin when no argument is given. With - --session, previous turns are sent as context. - chat Interactive multi-turn conversation. + ask "" 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 picks another. + capture 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 '. Options: --model Override the model for this invocation. - --session Persistent memory: ask/chat remember prior turns in + --session Use a named persistent session instead of 'default': ~/.local/share/linux_post_install/ai/.json - (capped at $MAX_SESSION_TURNS turns). ask without - --session stays one-shot. + (capped at $MAX_SESSION_TURNS turns). --system System instruction sent with every turn (kept out of the - session file), e.g. "Reply like a friendly Telegram chat". + 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 "Explain DNS in one line" + 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 work + pos ai gemini sessions reset default # forget default memory EOF exit 0 } @@ -91,10 +127,72 @@ resolve_model() { fi } +# ── --last: attach the most recent pos command output ─────────── +# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/_pos_.log +# (ai-gemini itself is interactive-logged, so it never creates its own output +# log). The 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 only touch the -# session layer when --session is given (otherwise they stay stateless). +# 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_-]/_}" @@ -156,29 +254,194 @@ gemini_generate() { 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 ". + 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 [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 + 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 \"\"" - require_key - if [ -n "$SESSION" ]; then - contents="$(session_load)" - contents="$(session_push "$contents" user "$prompt")" - else - contents="$(jq -nc --arg t "$prompt" '{contents:[{role:"user",parts:[{text:$t}]}]}')" + # --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="" + # Priority 1: most recent pos dispatcher log + log_file="$(newest_pos_log 2>/dev/null)" || true + # Priority 2: captured output from any command + if [ -z "$log_file" ] && [ -s "$LAST_CMD_OUTPUT_FILE" ]; then + log_file="$LAST_CMD_OUTPUT_FILE" + fi + [ -n "$log_file" ] || err "No recent output found — run 'pos ai gemini capture ' 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 - if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then + 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 - if [ -n "$SESSION" ]; then - contents="$(session_push "$contents" model "$out")" - session_save "$contents" - fi - printf '%s\n' "$out" + contents="$(session_push "$contents" model "$out")" + session_save "$contents" + render_markdown "$out" } cmd_chat() { @@ -186,12 +449,8 @@ cmd_chat() { local model contents text answer model="$(resolve_model)" require_key - if [ -n "$SESSION" ]; then - contents="$(session_load)" - printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')" - else - contents='{"contents":[]}' - fi + 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 @@ -202,7 +461,7 @@ cmd_chat() { q|Q|quit|exit) echo; echo "bye"; return 0 ;; /reset) contents='{"contents":[]}' - [ -n "$SESSION" ] && session_save "$contents" + session_save "$contents" echo "[history cleared]" continue ;; esac @@ -212,8 +471,13 @@ cmd_chat() { continue fi contents="$(session_push "$contents" model "$answer")" - [ -n "$SESSION" ] && session_save "$contents" - printf '\n%s\n\n' "$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 @@ -276,6 +540,8 @@ cmd_models() { # ── Parse flags + subcommand ──────────────────────────────────── MODEL_OVERRIDE="" +FULL_MODE=0 +LAST_MODE=0 cmd="" args=() while [ $# -gt 0 ]; do @@ -290,6 +556,10 @@ while [ $# -gt 0 ]; do --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 @@ -301,11 +571,16 @@ while [ $# -gt 0 ]; do 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 " +fi + case "${cmd:-}" in - "") usage ;; - ask) cmd_ask "${args[@]}" ;; - chat) cmd_chat "${args[@]}" ;; - models) cmd_models "${args[@]}" ;; - sessions) cmd_sessions "${args[@]}" ;; - *) err "Unknown ai gemini subcommand '$cmd' (see --help)" ;; + "") 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 diff --git a/bin/pos-ai-openrouter b/bin/pos-ai-openrouter new file mode 100755 index 0000000..ac39515 --- /dev/null +++ b/bin/pos-ai-openrouter @@ -0,0 +1,587 @@ +#!/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 < [--model ] [--session ] [--system ] [--full] [--last] + +Chat with OpenRouter models via the REST API (openrouter.ai). + +Subcommands: + ask "" 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 picks another. + capture 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 '. + +Options: + --model Override the model for this invocation. + --session Use a named persistent session instead of 'default': + ~/.local/share/linux_post_install/ai-openrouter/.json + (capped at $MAX_SESSION_TURNS turns). + --system 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/_pos_.log +# (ai-openrouter itself is interactive-logged, so it never creates its own output +# log). The 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 ". + 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 [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 \"\"" + # --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="" + # Priority 1: most recent pos dispatcher log + log_file="$(newest_pos_log 2>/dev/null)" || true + # Priority 2: captured output from any command + if [ -z "$log_file" ] && [ -s "$LAST_CMD_OUTPUT_FILE" ]; then + log_file="$LAST_CMD_OUTPUT_FILE" + fi + [ -n "$log_file" ] || err "No recent output found — run 'pos ai openrouter capture ' 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="$2" + if rm -f "$(session_file "$name")"; then + ok "session '$name' cleared" + fi + ;; + *) err "Unknown sessions subcommand '$action' (list | reset )" ;; + 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 " +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 diff --git a/lib/pos-ai-hook.sh b/lib/pos-ai-hook.sh new file mode 100644 index 0000000..a058eea --- /dev/null +++ b/lib/pos-ai-hook.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Optional shell hook for pos ai * --last: auto-captures terminal output. +# Usage: add to ~/.bashrc: +# source /usr/local/bin/pos-ai-hook.sh +# — or — +# source /path/to/Linux_post_install/lib/pos-ai-hook.sh +# +# 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 +# pick it up automatically — no 'capture' subcommand needed. +# To disable: unset __POS_CAPTURE_ACTIVE + +__POS_CAPTURE_FILE="${HOME}/.local/share/linux_post_install/last_cmd_output" +__POS_CAPTURE_MAX=${__POS_CAPTURE_MAX:-1048576} # 1 MB, override with env + +# Truncate if oversized (keep last half) +if [ -f "$__POS_CAPTURE_FILE" ]; then + __sz=$(stat -c%s "$__POS_CAPTURE_FILE" 2>/dev/null || echo 0) + if [ "$__sz" -gt "$__POS_CAPTURE_MAX" ]; then + tail -c $((__POS_CAPTURE_MAX / 2)) "$__POS_CAPTURE_FILE" > "${__POS_CAPTURE_FILE}.tmp" 2>/dev/null + mv -- "${__POS_CAPTURE_FILE}.tmp" "$__POS_CAPTURE_FILE" + fi +else + : > "$__POS_CAPTURE_FILE" +fi + +# Only activate in interactive terminals, not already redirected +if [ -t 1 ] && [ -t 2 ] && [ -z "${__POS_CAPTURE_ACTIVE:-}" ]; then + export __POS_CAPTURE_ACTIVE=1 + exec > >(tee -a "$__POS_CAPTURE_FILE" 2>&1) 2>&1 +fi