#!/usr/bin/env bash
# offload-ref.sh  -  park a bulky tool payload on disk and print a pointer instead.
#
# The dominant token cost of a long phase is not the code, it is the machinery
# around it: a full xcodebuild log, a whole diff, a test run. Phase 3 already
# tees its build output to a file, but nothing decided how much of that file
# reaches the model, so in practice all of it did - tens of thousands of tokens
# whose useful content is the last twenty lines and the exit status.
#
# This is that decision, as a filter. Pipe a payload through it and the full
# text lands under .multi-agent/refs/<node_id>.md while stdout carries a stub:
# one header line, the tail, and the node id that buys the rest back. Nothing is
# lost, and nothing that was not asked for is paid for.
#
# Usage:
#   <command> 2>&1 | offload-ref.sh --phase 3 --label build [--tail N] [--root DIR]
#   offload-ref.sh --phase 4 --label diff --input path/to/file
#
# Flags:
#   --phase N     pipeline phase, recorded in the node id (default 0)
#   --label NAME  what the payload is: build, tests, diff, ... (default payload)
#   --tail N      lines of the payload to keep inline (default: pref, else 20)
#   --root DIR    repo/worktree root (default: git toplevel, else $PWD)
#   --input FILE  read from FILE instead of stdin
#   --min-lines N below this the payload is passed through untouched
#                 (default: pref, else 40)
#
# Honors prefs.global.contextOffload: `enabled`, `minLines` and `tailLines`.
# When enabled is off, or absent, the payload passes through byte-for-byte: a
# caller can pipe through this unconditionally without changing behaviour for a
# user who has not opted in. An explicit flag beats the pref.
#
# Exit codes: 0 always when the payload was handled (the caller's own exit status
# is its own business - this sits in a pipe and must not mask it); 1 on usage error.

set -uo pipefail

PHASE="0"
LABEL="payload"
ROOT=""
INPUT=""
# Empty until parsing is done, so a flag can be told apart from a default and
# the pref only fills what the caller left open.
TAIL_LINES=""
MIN_LINES=""

while [ $# -gt 0 ]; do
  case "$1" in
    --phase) PHASE="${2:?--phase needs a value}"; shift 2 ;;
    --label) LABEL="${2:?--label needs a value}"; shift 2 ;;
    --tail) TAIL_LINES="${2:?--tail needs a value}"; shift 2 ;;
    --root) ROOT="${2:?--root needs a value}"; shift 2 ;;
    --input) INPUT="${2:?--input needs a value}"; shift 2 ;;
    --min-lines) MIN_LINES="${2:?--min-lines needs a value}"; shift 2 ;;
    -h|--help) sed -n '2,32p' "$0"; exit 0 ;;
    *) echo "offload-ref: unknown argument '$1'" >&2; exit 1 ;;
  esac
done

# GNU coreutils ships sha256sum; macOS ships shasum. Neither is on every host we
# support, so name both (see smoke-shell-portability.sh).
sha256_hex() {
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256
  else
    # No hasher: fall back to a length-and-content digest that is stable within
    # a run. A degraded node id still points at the right file.
    cksum
  fi
}

# Resolve the whole contextOffload block in one read, rather than shelling out
# to jq once per field. Sets PREF_ENABLED / PREF_MIN_LINES / PREF_TAIL_LINES.
#
# minLines and tailLines were declared in prefs.schema.json and read by nothing:
# a user who set tailLines to 50 got 20 and had no way to tell. Config that does
# nothing is worse than config that is absent - it documents a control that is
# not there.
PREF_ENABLED="false"
PREF_MIN_LINES=""
PREF_TAIL_LINES=""
read_prefs() {
  local prefs values
  # Same search order as memory-load.sh. Reading fewer paths than the sibling
  # script means the filter is silently off for an XDG or legacy-named install
  # while the rest of the memory layer is on.
  for prefs in \
    "$HOME/.claude/multi-agent-preferences.json" \
    "$HOME/.config/multi-agent-pipeline/multi-agent-preferences.json" \
    "$HOME/.claude/preferences.json" \
    "$HOME/.config/multi-agent-pipeline/preferences.json"
  do
    [ -f "$prefs" ] || continue
    command -v jq >/dev/null 2>&1 || return 0
    values=$(jq -r '
      .global.contextOffload // {} |
      [(.enabled // false), (.minLines // ""), (.tailLines // "")] | @tsv
    ' "$prefs" 2>/dev/null) || return 0
    PREF_ENABLED=$(printf '%s' "$values" | cut -f1)
    PREF_MIN_LINES=$(printf '%s' "$values" | cut -f2)
    PREF_TAIL_LINES=$(printf '%s' "$values" | cut -f3)
    return 0
  done
  return 0
}

# A pref value only counts when it is a positive integer; anything else falls to
# the shipped default rather than silently disabling the threshold.
resolve_int() {
  case "$2" in
    "" | *[!0-9]*) printf '%s' "$3" ;;
    0) printf '%s' "$3" ;;
    *) printf '%s' "$2" ;;
  esac
}

# Buffer the payload: it has to be measured before it can be decided about, and
# stdin is not seekable.
BUF="$(mktemp -t offload-ref.XXXXXX)"
trap 'rm -f "$BUF"' EXIT
if [ -n "$INPUT" ]; then
  [ -f "$INPUT" ] || { echo "offload-ref: input not found: $INPUT" >&2; exit 1; }
  cat "$INPUT" > "$BUF"
else
  cat > "$BUF"
fi

LINES=$(wc -l < "$BUF" | tr -d ' ')
BYTES=$(wc -c < "$BUF" | tr -d ' ')

# Pass-through paths. Both print the payload unchanged, so a pipeline that
# always pipes through this filter behaves identically for a user who has the
# pref off and for a payload too small to be worth a round trip.
read_prefs
[ "$PREF_ENABLED" = "true" ] || { cat "$BUF"; exit 0; }

# Flag wins, then pref, then the shipped default.
[ -n "$MIN_LINES" ] || MIN_LINES=$(resolve_int minLines "$PREF_MIN_LINES" 40)
[ -n "$TAIL_LINES" ] || TAIL_LINES=$(resolve_int tailLines "$PREF_TAIL_LINES" 20)

if [ "$LINES" -lt "$MIN_LINES" ]; then
  cat "$BUF"
  exit 0
fi

if [ -z "$ROOT" ]; then
  ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
  [ -n "$ROOT" ] || ROOT="$PWD"
fi

REFS_DIR="$ROOT/.multi-agent/refs"
# Every failure below falls back to printing the payload. A stub is a promise
# that the full text is retrievable; emitting one when the write failed turns a
# verbose log into a silently lost one, which is worse than not offloading.
mkdir -p "$REFS_DIR" 2>/dev/null || { cat "$BUF"; exit 0; }

# Never commit an offloaded payload: it is a build log, and the worktree it
# belongs to is deleted at the end of the task.
GITIGNORE="$ROOT/.multi-agent/.gitignore"
if [ ! -f "$GITIGNORE" ]; then
  printf '# Local run artefacts  -  never commit.\nmemory/\nrefs/\n' > "$GITIGNORE"
elif ! grep -q '^refs/$' "$GITIGNORE"; then
  printf 'refs/\n' >> "$GITIGNORE"
fi

SLUG=$(printf '%s' "$LABEL" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{1,\}/-/g; s/^-//; s/-$//')
[ -n "$SLUG" ] || SLUG="payload"
DIGEST=$(sha256_hex < "$BUF" | awk '{print substr($1,1,8)}')
NODE_ID="p${PHASE}-${SLUG}-${DIGEST}"
REF_FILE="$REFS_DIR/${NODE_ID}.md"

# A payload that contains its own ``` line closes the fence early and the rest
# of the log renders as prose. Build logs print backticks. CommonMark lets a
# longer fence contain a shorter one, so size the fence to the payload.
longest=$(grep -o '^`\{3,\}' "$BUF" 2>/dev/null | awk '{ if (length($0) > n) n = length($0) } END { print n + 0 }')
fence_len=3
[ "${longest:-0}" -ge 3 ] && fence_len=$((longest + 1))
FENCE=$(printf '%*s' "$fence_len" '' | tr ' ' '`')

{
  printf '# %s (phase %s)\n\n' "$LABEL" "$PHASE"
  printf -- '- node_id: %s\n- lines: %s\n- bytes: %s\n\n' "$NODE_ID" "$LINES" "$BYTES"
  printf '%s\n' "$FENCE"
  cat "$BUF"
  printf '\n%s\n' "$FENCE"
} > "$REF_FILE" 2>/dev/null || { cat "$BUF"; exit 0; }

KB=$(( (BYTES + 1023) / 1024 ))
printf '[[ref:%s]] %s  -  %s lines, %s KB, full text: %s\n' \
  "$NODE_ID" "$LABEL" "$LINES" "$KB" "${REF_FILE#"$ROOT"/}"
printf 'Last %s lines follow. Read the file above for the rest.\n' "$TAIL_LINES"
tail -n "$TAIL_LINES" "$BUF"
exit 0
