#!/bin/sh # hm -- the documented steps, in the order that works. # # Not a framework and not a new way to run anything: every subcommand below shells the same # scripts docs/INSTALL.md describes. It exists because the ORDER is load-bearing and getting it # wrong is silent. Build the ANN index before the rows exist and it is far slower; skip it and # search still answers, by sequential-scanning every vector for the life of the install. Re-run a # full ingest over an existing scope and it deletes the documents first, taking every embedding # with them. Neither raises an error. # # ./hm init bring up the docker stack, apply the migrations, print exports # ./hm migrate [--owner ] [--reapply ]... [--list | --status] # apply sql/ in the one order that works, recording each file's # SHA-256 in hm_schema_migrations. An applied file with the same # checksum is skipped, so a second run is a no-op. A file that # CHANGED after it was applied stops the run before anything is # applied; --reapply says you read the change and want it # re-run. --owner claims unreferenced legacy entities (default: # MEM_AUTHOR, else your login). --list prints the order, --status # what is applied, pending or changed. Connects as HM_ADMIN_URL, # else DATABASE_URL: it must be the role that owns the tables. # ./hm author add [] # create a login the DATABASE maps to (enforced identity: # it cannot read or write as anyone else, whatever it sets # mem.reader to) and print how to set its password # ./hm author remove unmap a login, revoke hm_app from it and disable it # ./hm author list which logins are mapped to which author (owner only) # ./hm ingest [--walk] # ingest -> embed -> index, incrementally if the scope already exists. # --walk enumerates the tree instead of asking git, for a directory # that is git-ignored or not a repository at all -- without it such a # directory ingests ZERO documents and says why. # A `.hmignore` in keeps paths OUT of the corpus (one # pattern per line, trailing '/' = whole subtree). For the file of # credentials and the superseded spec set that live next to the real # documents: --walk cannot exclude them, it is all-or-nothing. # ./hm refresh re-ingest every scope in $HM_SCOPES (default # ~/.config/hypermnesia/scopes: [--walk] per # line), then assert no chunk was left without an embedding. This is # the one to put on a timer -- freshness.py reports drift, this ends # it. # ./hm install [--wire] [--ref ] [--console[=]] [--dry-run] ... # install a COMMIT of this checkout as the machine's release: # ~/.HyperMnesia/current (HYPERMNESIA_HOME moves it), with the MCP # server built into it. Hooks, jobs and MCP configs then run that, # not this working copy. --wire rewrites the configs that still point # into a checkout (each one backed up first). ci/install.py --help. # ./hm doctor is this install actually working, or only appearing to # ./hm latency where the time goes: hook, embedder, database, reranker # # Env: DATABASE_URL (required by ingest/doctor; init prints the one it just set up). # HM_ADMIN_URL (migrate, author: the table owner's connection; default DATABASE_URL). set -eu ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) PY=${HM_PYTHON:-python3} die() { printf '%s\n' "hm: $*" >&2; exit 1; } step() { printf '\n== %s\n' "$*"; } need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not on PATH"; } # The header comment above IS the help text; printing it from the file keeps the two from # drifting. Stop at the first line that is not a comment rather than a hardcoded line number, # which goes stale the moment anyone edits the header. usage() { awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0" exit "${1:-1}" } # -- migrations ------------------------------------------------------------------------------- # THE apply order, and the only copy of it. CI, the docs and ci/doctor.py read it from here # (`./hm migrate --list`) instead of keeping their own: there used to be four copies, and nothing # noticed when one fell behind. # # schema.sql documents, chunks, the component map # migrate_schema_version.sql hm_schema_migrations: what has been applied, with checksums # schema_mem.sql .. dreamer personal memory, then its migrations in dependency order # migrate_author_roles.sql per-author logins (enforced identity) # mem_app_role.sql LAST: grants on, and re-states the policies for, all of it MIGRATIONS="schema.sql migrate_schema_version.sql schema_mem.sql mem_multiuser.sql migrate_entity_ownership.sql migrate_memory_perspectives.sql migrate_memory_evidence.sql migrate_peer_cards.sql migrate_dreamer.sql migrate_memory_lifecycle.sql migrate_constraint_sources.sql migrate_author_roles.sql mem_app_role.sql" # Re-applied whenever anything before it was applied in the same run, and whenever it changed: # it grants on everything above it, so a table added without re-running it is unreachable to the # application -- which looks like an empty store, not like an error. REPEATABLE="mem_app_role.sql" # Tolerated when absent, with a note. Every other missing file is an error. MAY_BE_ABSENT="" SQL_DIR=${HM_SQL_DIR:-$ROOT/sql} # How migrate reaches the database: `psql "$MIGRATE_URL"` from this host by default; `hm init` # switches it to the psql inside the compose container, which needs nothing installed here. MIGRATE_VIA=url MIGRATE_URL= mpsql() { if [ "$MIGRATE_VIA" = compose ]; then docker compose -f "$ROOT/deploy/docker/docker-compose.yml" exec -T postgres \ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@" else psql "$MIGRATE_URL" "$@" fi } in_list() { # in_list case " $2 " in *" $1 "*) return 0 ;; esac return 1 } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then s=$(sha256sum < "$1") elif command -v shasum >/dev/null 2>&1; then s=$(shasum -a 256 < "$1") else s=$(openssl dgst -sha256 -r < "$1"); fi s=${s%% *} case "$s" in ''|*[!0-9a-f]*) die "could not checksum $1" ;; esac [ "${#s}" -eq 64 ] || die "could not checksum $1" printf %s "$s" } recorded_sum() { # the checksum recorded for , or nothing awk -v n="$1" '$1 == n { print $2 }' "$mtmp/recorded" } record_applied() { # record_applied # Both values are safe to interpolate: the name comes from MIGRATIONS, the sum is 64 hex. mpsql -tAX -q -v ON_ERROR_STOP=1 -c "INSERT INTO public.hm_schema_migrations (name, checksum) VALUES ('$1', '$2') ON CONFLICT (name) DO UPDATE SET checksum = EXCLUDED.checksum, applied_at = now()" >/dev/null \ || die "sql/$1 was applied but could not be recorded -- re-run ./hm migrate" } cmd_migrate() { owner=${MEM_AUTHOR:-$(id -un)} reapply="" mode=apply while [ $# -gt 0 ]; do case "$1" in --list) mode=list ;; --status) mode=status ;; --owner) [ $# -ge 2 ] || usage; owner=$2; shift ;; --owner=*) owner=${1#--owner=} ;; --reapply) [ $# -ge 2 ] || usage; reapply="$reapply ${2##*/}"; shift ;; --reapply=*) v=${1#--reapply=}; reapply="$reapply ${v##*/}" ;; *) usage ;; esac shift done if [ "$mode" = list ]; then printf '%s\n' $MIGRATIONS return 0 fi all=$(printf '%s ' $MIGRATIONS) for r in $reapply; do in_list "$r" "$all" || die "--reapply $r: not a migration (see ./hm migrate --list)" done # The owner value lands in a psql variable that one migration interpolates as a literal. case "$owner" in *[!A-Za-z0-9._-]*|"") die "--owner must match [A-Za-z0-9._-]+ (got: $owner)" ;; esac if [ "$MIGRATE_VIA" = url ]; then need psql MIGRATE_URL=${HM_ADMIN_URL:-${DATABASE_URL:-}} [ -n "$MIGRATE_URL" ] || die "set HM_ADMIN_URL (or DATABASE_URL) to the table owner's connection string" fi mtmp=$(mktemp -d); trap 'rm -rf "$mtmp"' EXIT if ! has=$(mpsql -tAX -v ON_ERROR_STOP=1 \ -c "SELECT to_regclass('public.hm_schema_migrations') IS NOT NULL" 2>&1); then die "could not reach the database: $has" fi : > "$mtmp/recorded" if [ "$has" = t ]; then mpsql -tAX -v ON_ERROR_STOP=1 -F ' ' \ -c "SELECT name, checksum FROM public.hm_schema_migrations ORDER BY name" \ > "$mtmp/recorded" || die "could not read hm_schema_migrations" fi # Pass 1: decide everything before applying anything. A changed file found halfway through # would leave this run half applied and half refused. changed="" for f in $MIGRATIONS; do path="$SQL_DIR/$f" if [ ! -f "$path" ]; then in_list "$f" "$MAY_BE_ABSENT" || die "sql/$f is missing -- this checkout is incomplete" [ "$mode" != status ] || printf '%-32s absent from this checkout\n' "$f" continue fi sum=$(sha256_of "$path") rec=$(recorded_sum "$f") state=pending if [ -n "$rec" ] && [ "$rec" = "$sum" ]; then state=applied elif [ -n "$rec" ]; then if in_list "$f" "$REPEATABLE"; then state="changed (re-applied by the next run)" elif in_list "$f" "$reapply"; then state="changed (--reapply)" else state=CHANGED changed="$changed $f" fi fi [ "$mode" != status ] || printf '%-32s %s\n' "$f" "$state" done [ "$mode" != status ] || return 0 if [ -n "$changed" ]; then printf '!! changed since they were applied:%s\n' "$changed" >&2 printf '!! An applied migration is not replayed silently. Read the change, then run\n' >&2 printf '!! ./hm migrate --reapply \n' >&2 printf '!! for each one (every file in sql/ is written to be re-runnable).\n' >&2 die "refusing to migrate: nothing was applied" fi # Pass 2: apply. hm_schema_migrations does not exist until migrate_schema_version.sql has # run, so the files applied before it are recorded as soon as it has. recordable=$has owed="" unrecorded="" applied=0 current=0 for f in $MIGRATIONS; do path="$SQL_DIR/$f" if [ ! -f "$path" ]; then printf -- '-- sql/%s is not in this checkout -- skipped\n' "$f" continue fi sum=$(sha256_of "$path") rec=$(recorded_sum "$f") # Current, unless it was named in --reapply, or it is the grant file and something # before it has just been applied. if [ "$rec" = "$sum" ] && ! in_list "$f" "$reapply" \ && { ! in_list "$f" "$REPEATABLE" || [ "$applied" -eq 0 ]; }; then current=$((current + 1)) continue fi # Anything applied before the grant file can (re)install a policy or a table it has not # restated yet, and until it runs again the store is not in the shape it certifies. # Forget its record FIRST: a run interrupted between this file and the grant file then # still owes it, instead of finding every checksum current and applying nothing. if [ "$recordable" = t ] && ! in_list "$f" "$REPEATABLE" && [ -z "$owed" ]; then for r in $REPEATABLE; do mpsql -tAX -q -v ON_ERROR_STOP=1 \ -c "DELETE FROM public.hm_schema_migrations WHERE name = '$r'" >/dev/null \ || die "could not mark sql/$r as owed -- nothing was applied" done owed=t fi printf -- '-- applying sql/%s\n' "$f" # From stdin, so the same call works against the compose container, which sees the # files under another path. The output is shown only on failure: an idempotent re-run # prints a page of "already exists, skipping" notices that hide the line that matters. if ! mpsql -X -q -v ON_ERROR_STOP=1 -v owner="$owner" < "$path" > "$mtmp/out" 2>&1; then cat "$mtmp/out" >&2 die "sql/$f failed -- nothing after it was applied" fi applied=$((applied + 1)) [ "$f" != migrate_schema_version.sql ] || recordable=t unrecorded="$unrecorded $f:$sum" if [ "$recordable" = t ]; then for u in $unrecorded; do record_applied "${u%%:*}" "${u#*:}" done unrecorded="" fi done [ -z "$unrecorded" ] || die "applied but not recorded (no hm_schema_migrations):$unrecorded" if [ "$applied" -eq 0 ]; then printf -- '-- nothing to apply: all %s migration(s) are recorded with their current checksum\n' "$current" else printf '\nmigrations: %s applied, %s already current\n' "$applied" "$current" fi } # -- per-author logins ------------------------------------------------------------------------ cmd_author() { [ $# -ge 1 ] || usage sub=$1; shift # Arguments first, the database second: a bad name is refused before anything connects. case "$sub" in add) [ $# -ge 1 ] && [ $# -le 2 ] || usage case "$1" in *[!A-Za-z0-9._-]*|"") die "author must match [A-Za-z0-9._-]+ (got: $1)" ;; esac case "${2:-}" in *[!a-z0-9_]*) die "role must match [a-z0-9_]+ (got: $2)" ;; esac ;; remove) [ $# -eq 1 ] || usage case "$1" in *[!a-z0-9_]*|"") die "role must match [a-z0-9_]+ (got: $1)" ;; esac ;; list) ;; *) usage ;; esac need psql url=${HM_ADMIN_URL:-${DATABASE_URL:-}} [ -n "$url" ] || die "set HM_ADMIN_URL (or DATABASE_URL) to the table owner's connection string" atmp=$(mktemp -d); trap 'rm -rf "$atmp"' EXIT case "$sub" in add) name=$1; role=${2:-} if ! r=$(printf "SELECT mem.add_author_role(:'a', NULLIF(:'r', '')::name);\n" \ | psql "$url" -X -tA -q -v ON_ERROR_STOP=1 -v a="$name" -v r="$role" \ 2> "$atmp/err"); then die "could not create a login for $name: $(cat "$atmp/err")" fi cat <@:/ MEM_AUTHOR=$name # must match: a write under any other name is refused and check what the database says from there: psql "\$DATABASE_URL" -tAXc 'SELECT * FROM mem.whoami()' # $r|$name|enforced Project membership is separate: INSERT INTO mem.project_members (author, project) VALUES ('$name', ''); The boundary holds only while nobody else uses the shared hm_app password (docs/DESIGN.md). MSG ;; remove) if ! r=$(printf "SELECT mem.remove_author_role(:'r');\n" \ | psql "$url" -X -tA -q -v ON_ERROR_STOP=1 -v r="$1" 2> "$atmp/err"); then die "could not remove $1: $(cat "$atmp/err")" fi [ "$r" = t ] || die "$1 is not a mapped author login" printf 'Unmapped %s, revoked hm_app from it and disabled its login.\n' "$1" ;; list) psql "$url" -X -v ON_ERROR_STOP=1 \ -c "SELECT role, author, created_at FROM mem.author_roles ORDER BY author, role" ;; esac } cmd_init() { need docker env_file="$ROOT/deploy/docker/.env" if [ ! -f "$env_file" ]; then step "Writing $env_file" cp "$ROOT/deploy/docker/.env.example" "$env_file" # Generated, not chosen: a password typed in a hurry outlives the hurry. # Hex, not base64: a '/' or '+' in the password has to be percent-encoded in the # postgresql:// URI this prints, and a password that silently breaks the URI is worse # than a shorter one. pw=$(openssl rand -hex 16 2>/dev/null | tr -d '\n') \ || die "could not generate a password -- set POSTGRES_PASSWORD in $env_file yourself" [ -n "$pw" ] || die "empty password generated -- set POSTGRES_PASSWORD in $env_file yourself" # BSD and GNU sed disagree about -i, so write a new file and move it. sed "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$pw|" "$env_file" > "$env_file.tmp" mv "$env_file.tmp" "$env_file" chmod 600 "$env_file" else step "Keeping the existing $env_file" # never clobber a password already in use fi # shellcheck disable=SC1090 . "$env_file" : "${POSTGRES_USER:=hm}" "${POSTGRES_DB:=hypermnesia}" "${PG_PORT:=5432}" [ -n "${POSTGRES_PASSWORD:-}" ] || die "POSTGRES_PASSWORD is empty in $env_file" step "Starting Postgres and the embedder" (cd "$ROOT/deploy/docker" && docker compose up -d) step "Waiting for Postgres to accept connections" i=0 until docker compose -f "$ROOT/deploy/docker/docker-compose.yml" \ exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; do i=$((i + 1)) [ "$i" -lt 60 ] || die "Postgres did not come up in 60s -- 'docker compose logs postgres'" sleep 1 done # Every file is part of the current memory contract. Loading only the base schema leaves the # MCP catalog advertising perspectives, evidence and grounded context against tables that do # not exist. The owner value is used only to claim unreferenced rows from an older store. step "Applying the migrations (./hm migrate, through the container's psql)" MIGRATE_VIA=compose cmd_migrate --owner "${MEM_AUTHOR:-$(id -un)}" url="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:$PG_PORT/$POSTGRES_DB" cat <, which cascades to chunks and therefore to every # embedding -- a one-file edit costing a full re-embed, quietly. Taking the snapshot from the # database we are about to write to is the point: one from anywhere else would mark documents # "already stored" that were never there. # Do not collapse this into `|| die "cannot reach the store"`: a query that fails for any # other reason would then send the reader to debug Postgres connectivity while the store is # perfectly healthy. if ! n=$(psql "$DATABASE_URL" -tAX -v ON_ERROR_STOP=1 \ -c "SELECT count(*) FROM documents WHERE repo='$scope'" 2>&1); then die "could not count documents for scope '$scope': $n" fi if [ "$n" -gt 0 ]; then step "Scope '$scope' already holds $n document(s) -- ingesting incrementally" psql "$DATABASE_URL" -tAX -v ON_ERROR_STOP=1 -F' ' \ -c "SELECT path, content_hash FROM documents WHERE repo='$scope'" > "$tmp/known.tsv" "$PY" "$ROOT/ingest/ingest_repo.py" "$dir" "$scope" "$tmp/out.sql" \ --known-hashes "$tmp/known.tsv" $walk else step "First ingest of scope '$scope'" "$PY" "$ROOT/ingest/ingest_repo.py" "$dir" "$scope" "$tmp/out.sql" $walk fi step "Loading the documents" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f "$tmp/out.sql" step "Embedding what has no vector yet" # Not fatal here, and not ignored either. The documents are in and the index should still be # built, so the run carries on -- but a batch the embedder refused leaves chunks that only the # lexical leg can ever find, and the run must end non-zero saying so. embed_rc=0 EMBED_REPO="$scope" "$PY" "$ROOT/ingest/embed_chunks.py" || embed_rc=$? # After the rows exist, never before: building HNSW on an empty table then filling it is far # slower than the other way round. IF NOT EXISTS makes the re-run a no-op. step "Building the ANN index if it is missing" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q \ -c "CREATE INDEX IF NOT EXISTS chunks_embedding_hnsw ON chunks USING hnsw (embedding vector_cosine_ops)" step "Checking the result" HM_REPO="$scope" "$PY" "$ROOT/ci/doctor.py" || true [ "$embed_rc" -eq 0 ] || die "embedding failed: some chunks of '$scope' were not embedded (embed_chunks exit $embed_rc); they are found by exact words only until './hm ingest' is re-run with the embedder up" cat <notes--walk # /home/me/src/apiapi # # Tabs, not spaces: a directory with a space in its name is ordinary, and splitting on # whitespace would quietly ingest the wrong path or nothing at all. file=${HM_SCOPES:-$HOME/.config/hypermnesia/scopes} [ -f "$file" ] || die "no scope list at $file -- one '[--walk]' per line (or set HM_SCOPES)" [ -n "${DATABASE_URL:-}" ] || die "DATABASE_URL is not set (./hm init prints it)" need psql # One refresh at a time. `mkdir` is the atomic test-and-set every shell has. Two overlapping # runs would ingest the same scopes concurrently, and the second would delete and rebuild # chunks the first is embedding. lock="${TMPDIR:-/tmp}/hm-refresh.lock" if ! mkdir "$lock" 2>/dev/null; then die "another refresh holds $lock" fi trap 'rmdir "$lock" 2>/dev/null' EXIT INT TERM failed="" listed="" while IFS= read -r line; do case "$line" in ''|\#*) continue ;; esac dir=$(printf %s "$line" | cut -f1) scope=$(printf %s "$line" | cut -f2) extra=$(printf %s "$line" | cut -f3) [ -n "$scope" ] || die "malformed line in $file (need a TAB between the path and the scope): $line" # A scope on the list and not on disk is a misconfiguration, not a reason to skip # quietly: the list is hand-written, so a renamed or unmounted directory would drop a # whole scope out of every refresh from then on while the run still reported success. if [ ! -d "$dir" ]; then printf '!! %s: no such directory -- scope %s was NOT refreshed\n' "$dir" "$scope" >&2 failed="$failed $scope:missing" continue fi step "Refreshing $scope from $dir" # In a subshell, because cmd_ingest calls die() on a bad scope or an unreachable store and # one bad entry must not take the rest of the list with it. # # `set +e` around it, and `set -e` INSIDE it, and neither is decoration. A subshell that # is the condition of `if` or the left side of `||` has errexit suppressed for everything # in it -- and re-setting it inside does not bring it back (checked in dash and in bash's # sh mode). Written as `if ! ( cmd_ingest ... )`, the embed step could die with a # traceback and the ingest would carry on to build the index and report success. It did: # the first run of this command against a live store crashed on a missing psycopg2, said # nothing, and was only caught by the unembedded-chunk assertion at the very end. set +e # shellcheck disable=SC2086 # $extra is one optional flag or nothing, by construction ( set -e; cmd_ingest "$dir" "$scope" $extra ) rc=$? set -e [ "$rc" -eq 0 ] || failed="$failed $scope" listed="${listed:+$listed,}$scope" done < "$file" # Which scopes does the STORE hold that this list never names? Nothing ties the two together: # a directory ingested once by hand and never added to the list is never refreshed again, and # search keeps answering about it from whatever it held that day -- with no error, no empty # result and no sign that anything is wrong. That is this project's whole subject matter, and # the refresh command had the same blind spot it was written to close. # # A note and not a failure: a scope whose directory is gone for good is a legitimate resident # of the store, its documents still searchable history. Failing the run nightly over one would # teach whoever reads the output to stop reading it. step "Checking the list against the store" for r in $(psql "$DATABASE_URL" -tAX -c \ "SELECT repo FROM documents GROUP BY repo ORDER BY repo" 2>/dev/null); do case ",$listed," in *",$r,"*) continue ;; esac printf -- '-- %s: in the store, not in %s -- nothing refreshes it\n' "$r" "$file" done # The check that makes the loop mean something. A chunk with no embedding raises no error # anywhere: it is simply absent from the vector half of every search, while the document it # belongs to still counts as indexed and the scope still counts as refreshed. step "Checking that nothing is left unembedded" if ! left=$(psql "$DATABASE_URL" -tAX -v ON_ERROR_STOP=1 \ -c "SELECT count(*) FROM chunks WHERE embedding IS NULL" 2>&1); then printf '!! could not count unembedded chunks: %s\n' "$left" >&2 failed="$failed count" else case "$left" in 0) printf -- '-- all chunks embedded\n' ;; ''|*[!0-9]*) printf '!! unexpected count output: %s\n' "$left" >&2; failed="$failed count" ;; *) printf '!! %s chunk(s) left without an embedding\n' "$left" >&2 failed="$failed embeddings:$left" ;; esac fi [ -z "$failed" ] || die "refresh finished with problems:$failed" printf '\nrefresh clean\n' } [ $# -ge 1 ] || usage 0 sub=$1; shift case "$sub" in init) cmd_init "$@" ;; ingest) cmd_ingest "$@" ;; refresh) cmd_refresh "$@" ;; migrate) cmd_migrate "$@" ;; author) cmd_author "$@" ;; install) exec "$PY" "$ROOT/ci/install.py" "$@" ;; doctor) exec "$PY" "$ROOT/ci/doctor.py" "$@" ;; latency) exec "$PY" "$ROOT/ci/latency.py" "$@" ;; -h|--help|help) usage 0 ;; *) die "unknown command: $sub (try ./hm help)" ;; esac