feat: ai — gemini terse+render+last+session+machine, openrouter new tool, capture any command
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
This commit is contained in:
+321
-46
@@ -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 <<EOF
|
||||
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>]
|
||||
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>" 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 "<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> Persistent memory: ask/chat remember prior turns in
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns). ask without
|
||||
--session stays one-shot.
|
||||
(capped at $MAX_SESSION_TURNS turns).
|
||||
--system <text> 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/<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 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 <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
|
||||
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>\""
|
||||
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 <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
|
||||
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 <cmd>"
|
||||
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
|
||||
|
||||
Executable
+587
@@ -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 <<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=""
|
||||
# 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 <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
|
||||
Reference in New Issue
Block a user