diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/etc/profile.d/adaptive-loop.sh b/etc/profile.d/adaptive-loop.sh new file mode 100644 index 0000000..2eb11df --- /dev/null +++ b/etc/profile.d/adaptive-loop.sh @@ -0,0 +1,53 @@ +#!/bin/sh +export FX_SOURCES_DIR=/root/shell-project/contrib/fx/sources.d +. /root/shell-project/src/f.sh +. /root/shell-project/contrib/fx/fx.sh +. /root/shell-project/contrib/heal/heal.sh +cd /root/shell-project 2>/dev/null + +# --- Persistent learning state ------------------------------------------- +# f()/fx() key learned fixes in a file called "p" in the current directory. +# On a plain live-CD boot that file lives on tmpfs and every reboot forgets +# everything the shell has ever learned. +# +# If a second, writable disk is attached (anything that isn't the boot +# CD), use it as a raw flat store for "p" -- no filesystem, no mkfs. This +# live image never mounts modloop-virt into /lib/modules, so modprobe has +# nothing to load and mkfs.ext4/mount fail with "Invalid argument" +# regardless of e2fsprogs being installed; dd/tr are busybox builtins and +# need no kernel module at all, so they work unconditionally. Learned +# fixes are restored from the disk's first 1MB at boot, and a background +# loop flushes "p" back to it every few seconds. No such disk -> "p" +# stays on tmpfs exactly as before. +STATE_DEV="" +for d in /dev/vda /dev/vdb /dev/vdc /dev/sda /dev/sdb /dev/sdc /dev/xvda /dev/xvdb; do + [ -b "$d" ] && { STATE_DEV="$d"; break; } +done + +if [ -n "$STATE_DEV" ]; then + dd if="$STATE_DEV" bs=1M count=1 2>/dev/null | tr -d '\0' > /root/shell-project/p + # p.trace (fx()'s "which source suggested this fix" record) lives in + # the SECOND 1MB block of the same raw device via skip=1/seek=1 -- + # a distinct region, not appended after p, so growth of one file can + # never overwrite the other's flat 1MB slot. Requires STATE_DEV to be + # at least 2MB; a device only large enough for p's block silently + # leaves p.trace unpersisted rather than corrupting p. + dd if="$STATE_DEV" bs=1M skip=1 count=1 2>/dev/null | tr -d '\0' > /root/shell-project/p.trace + ( + while true; do + sleep 3 + [ -f /root/shell-project/p ] && dd if=/root/shell-project/p of="$STATE_DEV" bs=1M count=1 conv=notrunc 2>/dev/null + [ -f /root/shell-project/p.trace ] && dd if=/root/shell-project/p.trace of="$STATE_DEV" bs=1M seek=1 count=1 conv=notrunc 2>/dev/null + done + ) & + echo "AdaptiveOS: persistent learning state active on $STATE_DEV -- learned fixes AND fix provenance survive reboot" +fi + +echo '=========================================' +echo ' Welcome to AdaptiveOS' +echo ' Self-healing shell is live: f(), fx(), heal()' +echo ' Try: fx some-command-that-might-fail' +echo ' heal() does the same, plus logs a MISS to' +echo ' /var/log/glyphos-heal.log if nothing (not' +echo ' even the manual prompt) fixes it' +echo '=========================================' diff --git a/etc/profile.d/adaptive-setup.sh b/etc/profile.d/adaptive-setup.sh new file mode 100644 index 0000000..1803cc4 --- /dev/null +++ b/etc/profile.d/adaptive-setup.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# One-time (per boot) install of git and fzf -- needs network. +# thefuck is deliberately NOT auto-installed: it needs python3/py3-pip +# plus a C build toolchain for musl (no prebuilt wheels for some of its +# deps), which this minimal image does not carry, and which would be +# painfully slow to compile under emulation. Install it by hand if you +# want it: apk add python3 py3-pip gcc musl-dev python3-dev && +# pip3 install --break-system-packages thefuck +if [ ! -f /var/adaptive-setup-done ]; then + echo 'AdaptiveOS: bringing up network and installing git + fzf...' + ip link set eth0 up 2>/dev/null + ip addr add 10.0.2.15/24 dev eth0 2>/dev/null + ip route add default via 10.0.2.2 2>/dev/null + echo 'nameserver 10.0.2.3' > /etc/resolv.conf + if timeout 5 ping -c1 -W3 1.1.1.1 >/dev/null 2>&1; then + grep -q 'dl-cdn.alpinelinux.org/alpine/v3.19/main' /etc/apk/repositories || echo 'http://dl-cdn.alpinelinux.org/alpine/v3.19/main' >> /etc/apk/repositories + grep -q 'dl-cdn.alpinelinux.org/alpine/v3.19/community' /etc/apk/repositories || echo 'http://dl-cdn.alpinelinux.org/alpine/v3.19/community' >> /etc/apk/repositories + : > /tmp/adaptive-setup.log + timeout 60 apk update -q >>/tmp/adaptive-setup.log 2>&1 + timeout 90 apk add -q git fzf >>/tmp/adaptive-setup.log 2>&1 + touch /var/adaptive-setup-done + echo 'AdaptiveOS: git + fzf installed. (log: /tmp/adaptive-setup.log)' + else + echo 'AdaptiveOS: no network reachable -- skipping git/fzf install' + touch /var/adaptive-setup-done + fi +fi diff --git a/root/shell-project/contrib/fx/builtin-fixes.txt b/root/shell-project/contrib/fx/builtin-fixes.txt new file mode 100644 index 0000000..30774ed --- /dev/null +++ b/root/shell-project/contrib/fx/builtin-fixes.txt @@ -0,0 +1,3 @@ +python=apk add --no-cache python3 && ln -sf /usr/bin/python3 /usr/bin/python +pip=apk add --no-cache py3-pip +python3=apk add --no-cache python3 diff --git a/root/shell-project/contrib/fx/fx.sh b/root/shell-project/contrib/fx/fx.sh new file mode 100644 index 0000000..be805a7 --- /dev/null +++ b/root/shell-project/contrib/fx/fx.sh @@ -0,0 +1,152 @@ +#!/bin/sh +# fx: automates f()'s "enter a fix" prompt by gathering candidate fixes +# from a directory of pluggable sources and letting you pick one (or +# auto-picking the top one) before piping it into f's stdin. +# +# Zero changes to f.sh: this only decides what gets typed at the +# "[LEARN] ... Enter a fix" prompt, using the stdin f() already reads. +# +# Usage: +# . src/f.sh +# . contrib/fx/fx.sh +# fx some-command --that --might --fail +# +# Env vars: +# FX_SOURCES_DIR the sources directory. Defaults to ./contrib/fx/sources.d +# (i.e. source this file from the repo root). There is no +# portable way for a *sourced* POSIX shell file to learn +# its own path -- $0 is the enclosing shell's name, and +# bash's BASH_SOURCE has no equivalent in dash/ash, so +# this does not try to be clever about it. Anything +# other than "source from repo root" (a different cwd, +# a temp dir a la bootstrap.sh) must set this explicitly +# *before* sourcing this file. +# FX_AUTO if set, auto-pick the top candidate instead of +# showing a picker +# FX_NO_PROBE if set, skip fx's own diagnostic run of the command +# (cheaper, but the 30-selfdiag.sh source gets no +# output to read and will have no opinion) +# FX_TRACE_FILE where chosen fixes' source labels are recorded, one +# "cmd=source-file" line per accepted fix. Defaults to +# ./p.trace, alongside f()'s own ./p. Written whenever +# a non-blank candidate is handed to f() -- same +# "wrote intent, not confirmed success" semantics as +# p itself (see f.sh). This exists because f() alone +# has no notion of *where* a fix came from: once a +# fix is in p, a manually-typed fix, a locally-known +# fix (10-known.sh), and an LLM-guessed fix +# (70/80-*.sh) are byte-for-byte indistinguishable. +# 80-remote-api.sh's own docstring is explicit that +# LLM-sourced fixes must stay visually distinguishable +# from verified ones -- without this file, that +# distinction is silently lost the moment a fix is +# accepted, and permanently lost if p is disk-persisted +# across a reboot (see adaptive-loop.sh). p.trace is +# what a caller checks before trusting an auto-applied +# fix it didn't personally review. +# +# Source contract (see sources.d/*.sh): +# - any executable file in $FX_SOURCES_DIR +# - invoked as: sourcefile "$CMD" +# - may read $FX_OUTPUT (captured stdout+stderr of one real attempt, +# empty if FX_NO_PROBE was set) +# - prints zero or more candidate fix lines to stdout, one per line +# - non-zero exit or no output = "no opinion", silently skipped +# - filename prefix (NN-name) sets display/consideration order +# +# Adding a source: drop a new executable file in sources.d/ that follows +# the contract above. Nothing else to register or edit. + +# Resolved to an absolute path *now*, at source time -- a relative +# default would silently re-resolve against whatever cwd happens to be +# active later when fx() is actually called, which is almost never the +# repo root in practice. +FX_SOURCES_DIR="${FX_SOURCES_DIR:-$(pwd)/contrib/fx/sources.d}" +FX_TRACE_FILE="${FX_TRACE_FILE:-p.trace}" + +fx() { + if [ ! -d "$FX_SOURCES_DIR" ]; then + echo "fx: FX_SOURCES_DIR '$FX_SOURCES_DIR' not found -- no sources will fire." >&2 + echo "fx: set FX_SOURCES_DIR before sourcing fx.sh if not running from the repo root." >&2 + fi + cmd="$*" + + FX_OUTPUT="" + if [ -z "$FX_NO_PROBE" ]; then + FX_OUTPUT=$("$@" 2>&1) + if [ $? -eq 0 ]; then + printf '%s\n' "$FX_OUTPUT" + return 0 + fi + fi + export FX_OUTPUT + + # Under FX_AUTO only the first candidate is ever used (head -1 + # below), so gathering every source unconditionally would mean + # always paying for the 25-30s local-LLM/remote-API tiers even + # when a free source already answered and that answer is + # guaranteed to win -- exactly the cost the NN- numbering claims to + # avoid. Stop at the first source that produces anything once + # FX_AUTO is set. Without it (the interactive picker), keep + # gathering everything: a later, better-verified candidate + # shouldn't be hidden behind an earlier weak match when a human is + # the one choosing. + candidates=$( + for src in "$FX_SOURCES_DIR"/*; do + [ -x "$src" ] || continue + label="${src##*/}" + out=$("$src" "$cmd" 2>/dev/null) + if [ -n "$out" ]; then + printf '%s\n' "$out" | while IFS= read -r line; do + [ -n "$line" ] && printf '%s\t%s\n' "$label" "$line" + done + [ -n "$FX_AUTO" ] && break + fi + done | awk -F'\t' ' + { + label = $1 + text = substr($0, length(label) + 2) + if (!seen[text]++) print "[" label "] " text + } + ' + ) + + # chosen_line keeps the "[source-label] fix text" form intact so + # provenance can be recovered after picking -- stripping the label + # too early (as this used to do in each branch below) throws away + # the one piece of information that lets a later reader tell an + # LLM-guessed fix apart from a locally-verified one. + chosen_line="" + if [ -n "$candidates" ]; then + if [ -n "$FX_AUTO" ]; then + chosen_line=$(printf '%s\n' "$candidates" | head -1) + elif command -v fzf >/dev/null 2>&1; then + chosen_line=$(printf '%s\n' "$candidates" | fzf --prompt="fix for: $cmd > " --height=40%) + else + i=0 + printf '%s\n' "$candidates" | while IFS= read -r line; do + i=$((i+1)); printf '%d) %s\n' "$i" "$line" + done + # "blank to give up", not "type your own": fx already + # committed f's stdin to this pipe, so a blank answer here + # can't fall through to a real terminal read the way f()'s + # own bare prompt does -- it just pipes an empty line into + # f(), same as when zero candidates were found at all. + printf 'pick a number (blank to give up): ' + read -r n + [ -n "$n" ] && chosen_line=$(printf '%s\n' "$candidates" | sed -n "${n}p") + fi + fi + + chosen=$(printf '%s\n' "$chosen_line" | sed 's/^\[[^]]*\] //') + chosen_source=$(printf '%s\n' "$chosen_line" | sed -n 's/^\[\([^]]*\)\].*/\1/p') + + # Same "record intent, not confirmed success" timing as f()'s own + # write to p: logged as soon as a non-blank fix is handed off, not + # gated on the retry inside f() actually succeeding. + if [ -n "$chosen" ] && [ -n "$chosen_source" ]; then + printf '%s=%s\n' "$cmd" "$chosen_source" >> "$FX_TRACE_FILE" + fi + + printf '%s\n' "$chosen" | f "$@" +} diff --git a/root/shell-project/contrib/fx/sources.d/10-known.sh b/root/shell-project/contrib/fx/sources.d/10-known.sh new file mode 100644 index 0000000..6d4cf54 --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/10-known.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Fuzzy-known source: surfaces fixes already taught for a command that +# *starts the same way* as this one, since f()'s own p lookup only ever +# matches the exact, literal string. +# +# Matches only against the KEY (left of "="), not the whole line -- an +# earlier version used `grep -F "$first_word " p`, which also matched +# inside a fix's *value*. Proven wrong live: with p containing +# `gti --version=gti(){ command git "$@"; }`, querying "git push" (never +# taught) matched that line anyway, because "git " appears inside the +# value text, not because the key is related. +[ -f p ] || exit 0 +cmd="$1" +first_word=$(printf '%s' "$cmd" | awk '{print $1}') +[ -n "$first_word" ] || exit 0 +awk -v fw="$first_word " ' + substr($0, 1, length(fw)) == fw { + line = $0 + sub(/^[^=]*=/, "", line) + if (!seen[line]++) print line + } +' p 2>/dev/null diff --git a/root/shell-project/contrib/fx/sources.d/15-builtin-fixes.sh b/root/shell-project/contrib/fx/sources.d/15-builtin-fixes.sh new file mode 100644 index 0000000..52faa69 --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/15-builtin-fixes.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Built-in fixes source: a small, curated, read-only fixes list shipped +# inside the image itself (contrib/fx/builtin-fixes.txt) -- zero network, +# zero API key, zero secrets, works on a completely offline boot. Same +# "cmd=fix" line format as the learned p file and 90-team-shared.sh, exact +# match on the full attempted command line (not fuzzy -- these are curated, +# not guesses, so they should either match precisely or stay out of the way). +# +# Numbered 15: after the user's own learned-prefix fixes (10-known.sh), +# since something the user already solved themselves should always win +# over our shipped default. Before history/self-diag/pathfuzzy (20-40), +# since a verified curated fix beats a blind guess. +BUILTIN_FIXES="${FX_BUILTIN_FIXES:-/root/shell-project/contrib/fx/builtin-fixes.txt}" +[ -f "$BUILTIN_FIXES" ] || exit 0 +cmd="$1" +[ -n "$cmd" ] || exit 0 +grep -F "${cmd}=" "$BUILTIN_FIXES" 2>/dev/null | cut -d= -f2- | awk '!seen[$0]++' diff --git a/root/shell-project/contrib/fx/sources.d/20-history.sh b/root/shell-project/contrib/fx/sources.d/20-history.sh new file mode 100644 index 0000000..5e9f98e --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/20-history.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# History source: suggests the closest-looking command you've actually +# typed and moved on from before (thefuck's history.py idea). Reads +# HISTFILE directly since a separate process doesn't inherit the parent +# shell's in-memory history. +# +# Single awk pass over the tail of the file: on weak/emulated hardware +# every avoided fork+exec matters (measured: an unoptimized PATH walk in +# 40-pathfuzzy.sh took >30s on this box before being fixed; pipeline +# depth has the same tax, just smaller per-hop) -- a five-process +# tail|grep|grep|awk|tail chain does the same job as one awk script +# reading the file once. +cmd="$1" +first_word=$(printf '%s' "$cmd" | awk '{print $1}') +[ -n "$first_word" ] || exit 0 +hf="${HISTFILE:-$HOME/.bash_history}" +[ -r "$hf" ] || exit 0 + +tail -n 300 "$hf" 2>/dev/null | awk -v first="$first_word" -v full="$cmd" ' +index($0, first " ") == 1 && $0 != full && !seen[$0]++ { buf[++n] = $0 } +END { + start = (n > 5) ? n - 4 : 1 + for (i = start; i <= n; i++) print buf[i] +} +' diff --git a/root/shell-project/contrib/fx/sources.d/30-selfdiag.sh b/root/shell-project/contrib/fx/sources.d/30-selfdiag.sh new file mode 100644 index 0000000..5fe26df --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/30-selfdiag.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Self-diagnosis source: many CLIs print their own corrected invocation +# in their error output (git's --set-upstream hint, apt's "did you +# mean", etc.). +# +# IMPORTANT: this must NOT cache the literal suggested line -- that +# reintroduces the exact-key staleness problem (e.g. "git push" learned +# once on branch A silently misapplies its captured --set-upstream on +# branch B, and B never actually gets pushed). Instead it emits a +# function that shadows the whole program and re-derives the suggestion +# from *live* output on every call, the same trick proven to generalize +# correctly across branches/arguments in practice. One taught fix this +# way covers every future self-diagnosing failure from that program, not +# just the one instance that happened to trigger the teaching prompt. +# +# The emitted function runs on every future call to that program for +# the rest of the session, so its own internal extraction is one awk +# pass, not a grep|sed|tail chain -- three fewer forks on every failure +# it ever handles, not just this first one. +cmd="$1" +prog=$(printf '%s' "$cmd" | awk '{print $1}') +[ -n "$prog" ] || exit 0 +[ -n "$FX_OUTPUT" ] || exit 0 + +# Literal prefix comparison (substr/index), never a regex built from +# $prog: proven broken live for program names containing ERE +# metacharacters -- e.g. "g++ foo.cpp" produced the pattern +# "^[[:space:]]*g++[[:space:]]", a malformed stacked-quantifier regex, +# and this source silently found nothing even when $FX_OUTPUT clearly +# started a line with "g++ ". Same fix applied to the emitted function +# below, since it runs the identical check on every future call. +printf '%s\n' "$FX_OUTPUT" | awk -v p="$prog" ' + { line = $0; sub(/^[ \t]*/, "", line); if (substr(line, 1, length(p) + 1) == p " ") { found = 1; exit } } + END { exit !found } +' || exit 0 + +cat <&1); ec=\$?; if [ \$ec -ne 0 ]; then sug=\$(printf '%s\\n' "\$out" | awk -v p="${prog}" '{ line=\$0; sub(/^[ \\t]*/,"",line); if (substr(line,1,length(p)+1)==p" ") s=line } END{print s}'); if [ -n "\$sug" ]; then eval "command \$sug"; return \$?; fi; fi; printf '%s\\n' "\$out"; return \$ec; } +FIX diff --git a/root/shell-project/contrib/fx/sources.d/40-pathfuzzy.sh b/root/shell-project/contrib/fx/sources.d/40-pathfuzzy.sh new file mode 100644 index 0000000..41720e2 --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/40-pathfuzzy.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# PATH-fuzzy source: if the first word isn't a known command, alias, or +# function, offer a function wrapping the closest-spelled real command +# as a candidate -- as a fix you approve through the normal f() flow, +# not a silently auto-installed hook. A function, not an alias: aliases +# only expand in interactive shells (need `shopt -s expand_aliases` +# otherwise), so an alias-based fix silently does nothing when f/fx run +# inside a script. +# +# Pure POSIX: walks $PATH directly instead of `compgen -c`, which is a +# bash-only builtin and does not exist under busybox ash -- this source +# used to be dead weight on exactly the minimal/embedded shells this +# project targets. Tested against busybox ash + busybox awk directly, +# not just bash + gawk. +# +# Uses Damerau-Levenshtein-lite (edit distance + adjacent transposition) +# so the canonical "gti" -> "git" typo scores 1, not 2 -- plain Hamming +# or substitution-only distance ties it with unrelated 3-letter commands +# like "ftp" and can pick the wrong one. +cmd="$1" +first_word=$(printf '%s' "$cmd" | awk '{print $1}') +[ -n "$first_word" ] || exit 0 +command -v "$first_word" >/dev/null 2>&1 && exit 0 + +IFS=: +for d in $PATH; do + ls "$d" 2>/dev/null +done | sort -u | awk -v target="$first_word" ' +function min3(a, b, c) { return (a < b ? (a < c ? a : c) : (b < c ? b : c)) } +function distance(a, b, n, m, i, j, ca, cb, cost, tmp) { + n = length(a); m = length(b) + if (n == 0) return m + if (m == 0) return n + for (j = 0; j <= m; j++) d[0, j] = j + for (i = 1; i <= n; i++) { + d[i, 0] = i + ca = substr(a, i, 1) + for (j = 1; j <= m; j++) { + cb = substr(b, j, 1) + cost = (ca == cb) ? 0 : 1 + tmp = min3(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + cost) + if (i > 1 && j > 1 && ca == substr(b, j-1, 1) && substr(a, i-1, 1) == cb) { + tmp = (tmp < d[i-2, j-2] + 1) ? tmp : d[i-2, j-2] + 1 + } + d[i, j] = tmp + } + } + return d[n, m] +} +{ + lendiff = length($0) - length(target) + if (lendiff > 2 || lendiff < -2) next + dd = distance(target, $0) + if (dd <= 2 && dd > 0 && (best == "" || dd < bestd)) { best = $0; bestd = dd } +} +END { if (best != "") print target "(){ command " best " \"$@\"; }" } +' diff --git a/root/shell-project/contrib/fx/sources.d/50-thefuck.sh b/root/shell-project/contrib/fx/sources.d/50-thefuck.sh new file mode 100644 index 0000000..05514ae --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/50-thefuck.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# thefuck bridge: only fires if thefuck is installed. Note that thefuck +# --yes decides *and executes* its own suggestion, so by the time this +# candidate is shown it may have already run once via thefuck -- treat +# it as informational, not a dry-run. +# +# Wrapped in `timeout`: thefuck has no fast-fail path when no rule +# matches -- fuzz-testing this source found it hanging past 3s on a +# majority of unmatched inputs (well beyond its own ~325ms best-case +# startup cost measured earlier). Every other source in this directory +# either runs in single-digit milliseconds or is itself already +# timeout-wrapped (70/80's HTTP calls); this one wasn't, so a single +# bad input could block the whole fx() pipeline indefinitely. Capped +# at 5s -- generous enough for a real correction (~2.8s measured +# earlier for the git-push case) without being unbounded. +command -v thefuck >/dev/null 2>&1 || exit 0 +command -v timeout >/dev/null 2>&1 || exit 0 +cmd="$1" +[ -n "$cmd" ] || exit 0 +timeout 5 thefuck --yes "$cmd" 2>/dev/null | tr -d '\342\200\213' | tail -1 diff --git a/root/shell-project/contrib/fx/sources.d/70-local-llm.sh b/root/shell-project/contrib/fx/sources.d/70-local-llm.sh new file mode 100644 index 0000000..7035b11 --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/70-local-llm.sh @@ -0,0 +1,106 @@ +#!/bin/sh +# Last-resort source: queries a local LLM inference server (Ollama or +# LM Studio, both common on dev machines) for a fix, only when nothing +# cheaper matched. Silent no-op if no local server is reachable -- this +# never touches the network, never calls a paid API, and costs nothing +# when you don't have a local model running. +# +# Numbered 70, after every deterministic/heuristic source (10-50) and +# before the team-shared source (90): cheap, verified candidates should +# always win first. fx.sh's FX_AUTO picks the *first* candidate in +# source order, so this only gets auto-selected when it's the only +# opinion offered at all -- still review it, this is a guess, not a +# fact. Every other default source is either a cached human decision or +# a deterministic rule; this is the one source capable of confidently +# suggesting something wrong. +# +# Needs python3 (uses urllib, no extra dependency beyond stdlib) -- +# silently no-ops without it rather than ship a fragile hand-rolled +# JSON encoder in shell. + +cmd="$1" +[ -n "$cmd" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +OLLAMA_URL="${FX_LLM_OLLAMA_URL:-http://127.0.0.1:11434}" +LMSTUDIO_URL="${FX_LLM_LMSTUDIO_URL:-http://127.0.0.1:1234}" + +python3 - "$cmd" "$FX_OUTPUT" "$OLLAMA_URL" "$LMSTUDIO_URL" <<'PYEOF' +import sys, json, urllib.request, urllib.error + +cmd, output, ollama_url, lmstudio_url = sys.argv[1:5] + +PROMPT = f"""You are suggesting a fix for a failed shell command, to be taught to a self-healing shell function called f(). +Rules: +- f() will eval your fix ONCE as a precondition, then re-run the ORIGINAL command verbatim. Your fix never replaces the command; it only sets up state so the original command then succeeds. +- If the fix should generalize beyond this one invocation, define a shell function with the same name as the failing program, wrapping the real binary via `command "$@"`, and only special-case the failure inside it. +- Respond with EXACTLY ONE LINE of POSIX shell and nothing else: no explanation, no markdown, no code fences, no commentary. + +Failing command: {cmd} + +Its output: +{output} +""" + +def http_json(url, payload, timeout): + req = urllib.request.Request( + url, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + +def http_get_json(url, timeout): + with urllib.request.urlopen(url, timeout=timeout) as r: + return json.loads(r.read().decode()) + +def try_ollama(): + tags = http_get_json(ollama_url.rstrip("/") + "/api/tags", 2) + models = tags.get("models") or [] + if not models: + return None + model = models[0].get("name") + if not model: + return None + resp = http_json( + ollama_url.rstrip("/") + "/api/generate", + {"model": model, "prompt": PROMPT, "stream": False}, + 25, + ) + return (resp.get("response") or "").strip() + +def try_lmstudio(): + models = http_get_json(lmstudio_url.rstrip("/") + "/v1/models", 2) + data = models.get("data") or [] + if not data: + return None + model = data[0].get("id") + if not model: + return None + resp = http_json( + lmstudio_url.rstrip("/") + "/v1/chat/completions", + { + "model": model, + "messages": [{"role": "user", "content": PROMPT}], + "temperature": 0, + }, + 25, + ) + choices = resp.get("choices") or [] + if not choices: + return None + return (choices[0].get("message", {}).get("content") or "").strip() + +for attempt in (try_ollama, try_lmstudio): + try: + result = attempt() + except (urllib.error.URLError, TimeoutError, OSError, ValueError, KeyError): + result = None + if result: + # Enforce the one-line contract even if the model ignores instructions. + first_line = result.splitlines()[0].strip() + first_line = first_line.strip("`") + if first_line: + print(first_line) + break +PYEOF diff --git a/root/shell-project/contrib/fx/sources.d/80-remote-api.sh b/root/shell-project/contrib/fx/sources.d/80-remote-api.sh new file mode 100644 index 0000000..af8d316 --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/80-remote-api.sh @@ -0,0 +1,101 @@ +#!/bin/sh +# Remote-API fallback source: queries any OpenAI-compatible chat +# completions endpoint (OpenAI itself, OpenRouter, Groq, Together, +# Anthropic's OpenAI-compatibility layer, a company-internal proxy, +# etc.) for a fix, only when nothing cheaper -- including the free +# local model in 70-local-llm.sh -- already matched. +# +# Off by default. Requires FX_REMOTE_API_URL, FX_REMOTE_API_KEY, and +# FX_REMOTE_API_MODEL to all be set explicitly; no default model is +# assumed, since guessing one could silently pick something you don't +# want to pay for. Silently no-ops if any of the three is missing. +# +# FX_REMOTE_API_URL is the exact URL to POST to -- no "/chat/completions" +# auto-appended. An earlier version guessed that suffix onto whatever +# base URL was given, which works for OpenAI/OpenRouter/Groq's actual +# convention but broke, silently, against a real third-party API that +# used a different path (a local FastAPI service with a plain "/chat" +# endpoint, not "/chat/.../completions" -- confirmed live: the guessed +# URL 404'd, the real one worked). Silent failure on a wrong guess is +# worse than requiring the full URL up front. +# +# Numbered 80: after the free local-LLM source (70) and before +# 90-team-shared.sh, which is a human-curated source and gets the final +# word. This is the most expensive tier in latency and real dollar +# cost, so it should only ever be the last thing tried. +# +# TRUST WARNING, sharper than every other source here: this is the one +# tier where the text you'd be eval'ing was not authored by you, a tool +# you run, or a URL you personally chose to trust -- it's a third-party +# service's best guess. fx.sh already labels every candidate with its +# source filename in the picker ("[80-remote-api.sh] ..."), so this is +# never visually indistinguishable from a self-diagnosed or locally- +# verified fix. Read that label before accepting. + +cmd="$1" +[ -n "$cmd" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +[ -n "$FX_REMOTE_API_URL" ] || exit 0 +[ -n "$FX_REMOTE_API_KEY" ] || exit 0 +[ -n "$FX_REMOTE_API_MODEL" ] || exit 0 + +# Some providers want "Authorization: Bearer " (OpenAI, OpenRouter, +# Groq, ...), others want a bare custom header (Anthropic's native API +# uses "x-api-key: " with no prefix). Both are configurable so this +# isn't hardcoded to one vendor's auth scheme. +AUTH_HEADER="${FX_REMOTE_API_AUTH_HEADER:-Authorization}" +AUTH_PREFIX="${FX_REMOTE_API_AUTH_PREFIX-Bearer }" + +python3 - "$cmd" "$FX_OUTPUT" "$FX_REMOTE_API_URL" "$FX_REMOTE_API_KEY" "$FX_REMOTE_API_MODEL" "$AUTH_HEADER" "$AUTH_PREFIX" <<'PYEOF' +import sys, json, urllib.request, urllib.error + +cmd, output, url, key, model, auth_header, auth_prefix = sys.argv[1:8] + +PROMPT = f"""You are suggesting a fix for a failed shell command, to be taught to a self-healing shell function called f(). +Rules: +- f() will eval your fix ONCE as a precondition, then re-run the ORIGINAL command verbatim. Your fix never replaces the command; it only sets up state so the original command then succeeds. +- If the fix should generalize beyond this one invocation, define a shell function with the same name as the failing program, wrapping the real binary via `command "$@"`, and only special-case the failure inside it. +- Respond with EXACTLY ONE LINE of POSIX shell and nothing else: no explanation, no markdown, no code fences, no commentary. + +Failing command: {cmd} + +Its output: +{output} +""" + +payload = { + "model": model, + "messages": [{"role": "user", "content": PROMPT}], + "temperature": 0, + "max_tokens": 200, +} + +req = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers={ + "Content-Type": "application/json", + auth_header: f"{auth_prefix}{key}", + }, + method="POST", +) + +try: + with urllib.request.urlopen(req, timeout=30) as r: + resp = json.loads(r.read().decode()) +except (urllib.error.URLError, TimeoutError, OSError, ValueError): + sys.exit(0) + +choices = resp.get("choices") or [] +if not choices: + sys.exit(0) +content = (choices[0].get("message", {}).get("content") or "").strip() +if not content: + sys.exit(0) + +# Enforce the one-line contract even if the model ignores instructions. +first_line = content.splitlines()[0].strip().strip("`") +if first_line: + print(first_line) +PYEOF diff --git a/root/shell-project/contrib/fx/sources.d/90-team-shared.sh b/root/shell-project/contrib/fx/sources.d/90-team-shared.sh new file mode 100644 index 0000000..336f5af --- /dev/null +++ b/root/shell-project/contrib/fx/sources.d/90-team-shared.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# EXAMPLE custom source -- a template, not a real dependency. This is +# the entire extension mechanism demonstrated: any executable dropped +# into sources.d/ following the contract in fx.sh is picked up +# automatically, no registration step anywhere else. +# +# Pulls a team-shared p-format corrections file and offers exact-key +# matches. Off by default (no-ops unless FX_TEAM_URL is set) because a +# match here becomes eval'd code the moment you select it -- point this +# at a URL you trust the same way you'd trust an rc file, never a +# random link. +[ -n "$FX_TEAM_URL" ] || exit 0 +cmd="$1" +[ -n "$cmd" ] || exit 0 +curl -fsSL "$FX_TEAM_URL" 2>/dev/null | grep -F "${cmd}=" | cut -d= -f2- diff --git a/root/shell-project/contrib/heal/heal.sh b/root/shell-project/contrib/heal/heal.sh new file mode 100644 index 0000000..32b767f --- /dev/null +++ b/root/shell-project/contrib/heal/heal.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# heal(): thin observability wrapper around fx(). fx()/f() (see +# ../fx/fx.sh, ../../src/f.sh) already do the actual healing across every +# tier plus the manual "[LEARN] ... Enter a fix" fallback -- this adds +# nothing to that logic. It only appends one structured line when every +# tier AND the manual prompt come up empty, so an external caller or +# orchestrator has something to grep for instead of only a bare nonzero +# exit code. +# +# Usage: +# . src/f.sh +# . contrib/fx/fx.sh +# . contrib/heal/heal.sh +# heal some-command-that-might-fail --with args +HEAL_LOG="${HEAL_LOG:-/var/log/glyphos-heal.log}" + +heal() { + if fx "$@"; then + return 0 + fi + ec=$? + ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown-time) + printf '%s MISS cmd="%s"\n' "$ts" "$*" >> "$HEAL_LOG" 2>/dev/null + return "$ec" +} diff --git a/root/shell-project/src/f.sh b/root/shell-project/src/f.sh new file mode 100644 index 0000000..c600702 --- /dev/null +++ b/root/shell-project/src/f.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Self-healing shell loop: run a command, and on failure ask for a fix, +# persist it keyed by the exact command line, then retry. On future +# calls with the same command, the learned fix is applied proactively +# before the command itself runs. +# +# State lives in ./p as "command=fix" lines, one per learned command. +# Copy p to another machine to transfer everything this shell has learned. +# +# Usage: +# . f.sh +# f some-command-that-might-fail --with args + +f() { + FIX="" + if [ -f p ]; then + while IFS= read -r line; do + case "$line" in + "$*="*) FIX="${line#"$*="}" ;; + esac + done < p + fi + + until { eval "$FIX" 2>/dev/null; "$@"; }; do + printf '\n[LEARN] "%s" failed. Enter a fix (blank to give up): ' "$*" >&2 + read -r n + [ -n "$n" ] || return 1 + # printf, not echo: POSIX leaves it implementation-defined whether + # echo interprets backslash escapes in its argument, and dash/ + # busybox ash both do by default (bash's echo doesn't, unless + # -e). A taught fix containing a literal "\n" -- e.g. any fix + # that itself uses printf '%s\n' internally, which is common and + # idiomatic -- got silently corrupted into a real embedded + # newline on write, breaking p's one-fix-per-line format the + # moment that fix was replayed. Confirmed live: identical fix + # text taught under bash was fine; the same text taught under + # `sh` (dash) truncated mid-line on replay. printf's %s never + # interprets escapes in the substituted value, under any shell. + printf '%s=%s\n' "$*" "$n" >> p + FIX="$n" + done +}