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

- bin/pos-ai: single provider-agnostic tool (ask/chat/sessions/capture/models/providers)
- lib/ai-providers/gemini.sh: Gemini adapter (59 ln)
- lib/ai-providers/openrouter.sh: OpenRouter adapter (59 ln)
- bin/pos-ai-gemini/openrouter: thin forwarders for backward compat
- Provider adapter interface: provider_name/default_model/generate/models_list
- Unified session format (OpenAI messages), auto-migrate old gemini contents
- Config: AI_PROVIDER/AI_API_KEY/AI_MODEL/AI_SYSTEM_PROMPT in ai.env
- Config fallback: AI_API_KEY → provider-specific env var → error
- Default system prompt configurable via AI_SYSTEM_PROMPT
- New subcommand: pos ai providers (lists providers + config status)
- Shell hook (pos-ai-hook.sh) for auto-capture
This commit is contained in:
Your Name
2026-08-25 09:57:10 -04:00
parent f0ef13827b
commit 4f79ce123f
14 changed files with 946 additions and 1324 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 "$@"