reliary-agent 0.9.5

Grammar-free symbol intelligence — CLI and MCP server.
// reliary gate.js v0.8.1 — THIN Pi adapter.
//
// What this shim does:
//   - Discover the reliary binary.
//   - After edit/write, trigger background FTS5 reindex.
//   - When RELIARY_SIFT_BASH=1 (default ON), intercept bash tool calls and pipe output through
//     `reliary wrap` (sift compression). Compresses tool output BEFORE the LLM
//     sees it — rtk's proven pattern. No cache bust because the LLM builds
//     reasoning on compressed text from the start.
//   - Log turn count for diagnostics.

const { execFileSync, spawnSync } = require("child_process");
const { existsSync } = require("fs");
const { join } = require("path");
const { homedir } = require("os");

const GATE_VERSION = "0.8.9";

const SIFT_BASH = process.env.RELIARY_SIFT_BASH !== "0"; // default ON
// Pack regeneration on edit: opt-in, default OFF.
// When ON, after edit/write tools fire, regenerates the holographic pack for the file's project.
// Cost: ~5s regen + cache miss on next turn ($0.001). Worth it only if the pack
// has stale data the model is likely to query against.
const PACK_REGEN_ON_EDIT = process.env.RELIARY_PACK_REGEN_ON_EDIT === "1"; // default OFF

let LOG_LEVEL = 3;
try {
  if (process.env.RELIARY_LOG === "error") LOG_LEVEL = 1;
  else if (process.env.RELIARY_LOG === "warn") LOG_LEVEL = 2;
  else if (process.env.RELIARY_LOG === "debug") LOG_LEVEL = 4;
  else if (process.env.RELIARY_LOG === "trace") LOG_LEVEL = 5;
} catch {}

function gateLog(level, msg) {
  const lv = { error: 1, warn: 2, info: 3, debug: 4, trace: 5 }[level] || 3;
  if (lv > LOG_LEVEL) return;
  console.error(`[gate] ${msg}`);
}

// Binary discovery — same locations as before, agent-agnostic.
// F7 hardening: parity with hooks/claude-pretooluse.sh — a candidate must be
// an executable regular file owned by us, else it is skipped. (The sh hook
// enforces 0700 cache + ownership + X_OK; gate.js previously accepted any
// existsSync path, letting a planted file redirect the hook to an
// attacker-controlled binary.)
function isTrustedBin(c) {
  try {
    const fs = require("fs");
    const st = fs.statSync(c);
    if (!st.isFile()) return false;
    try { fs.accessSync(c, fs.constants.X_OK); } catch { return false; }
    try {
      if (typeof st.uid === "number" && typeof process.getuid === "function"
          && st.uid !== process.getuid()) return false;
    } catch {}
    return true;
  } catch { return false; }
}
let RELIARY_BIN = process.env.RELIARY_BIN_PATH || null;
if (RELIARY_BIN && !isTrustedBin(RELIARY_BIN)) RELIARY_BIN = null;
if (!RELIARY_BIN) {
  try {
    const which = execFileSync("which", ["reliary"], { encoding: "utf-8", timeout: 2000 });
    const cand = which && which.trim();
    if (cand && isTrustedBin(cand)) RELIARY_BIN = cand;
  } catch {}
}
if (!RELIARY_BIN) {
  // Fall back to the old binary name during the transition window.
  try {
    const which = execFileSync("which", ["reliary-agent"], { encoding: "utf-8", timeout: 2000 });
    const cand = which && which.trim();
    if (cand && isTrustedBin(cand)) RELIARY_BIN = cand;
  } catch {}
}
if (!RELIARY_BIN) {
  for (const c of ["/usr/local/bin/reliary", "/usr/bin/reliary",
                   "/usr/local/bin/reliary-agent", "/usr/bin/reliary-agent",
                   // ~/.local/bin is the documented install location and is
                   // on the interactive PATH, but agent-spawned processes
                   // often run with a reduced PATH. Check it explicitly.
                   join(homedir(), ".local", "bin", "reliary"),
                   join(homedir(), ".local", "bin", "reliary-agent")]) {
    if (isTrustedBin(c)) { RELIARY_BIN = c; break; }
  }
}

gateLog("info", `v${GATE_VERSION} thin shim — reliary: ${RELIARY_BIN ? "found" : "missing"}${RELIARY_BIN ? ` (${RELIARY_BIN.split("/").pop()})` : ""}; pack_regen_on_edit: ${PACK_REGEN_ON_EDIT}`);

let sessionTurns = 0;

// After a file-modifying tool succeeds, refresh the FTS5 index for that file so the
// LLM's next search reflects reality. ~5ms, best-effort, never blocks the agent.
function triggerReindex(filePath) {
  if (!filePath || !RELIARY_BIN) return;
  try {
    execFileSync(RELIARY_BIN, ["reindex-file", filePath], { stdio: "ignore", timeout: 5000 });
  } catch {
    // Best-effort: reindex failure must never break the agent's flow.
  }
}

// Trigger full pack regeneration. HEAVY: 5-10s for large repos, busts the cache for the pack prefix.
// Only call this from handleToolResult where latency doesn't matter and the user just edited code.
// To find project root, walk up from filePath looking for .reliary/index.sqlite.
function triggerPackRegen(filePath) {
  if (!filePath || !RELIARY_BIN || !PACK_REGEN_ON_EDIT) return;
  try {
    const fs = require("fs");
    const path = require("path");
    let dir = path.dirname(path.resolve(filePath));
    let projectRoot = null;
    for (let i = 0; i < 20; i++) {
      if (fs.existsSync(path.join(dir, ".reliary", "index.sqlite"))) {
        projectRoot = dir;
        break;
      }
      const parent = path.dirname(dir);
      if (parent === dir) break;
      dir = parent;
    }
    if (!projectRoot) return;
    execFileSync(RELIARY_BIN, ["pack", projectRoot, "--format", "l2l3", "--strategy", "full"], {
      stdio: "ignore", timeout: 30000,
    });
    gateLog("debug", `pack regenerated for ${projectRoot}`);
  } catch (e) {
    gateLog("debug", `pack regen failed: ${e.message ? e.message.slice(0, 60) : "unknown"}`);
  }
}

// Hook: tool_call — reindex after writes/edits + optional bash sift.
function handleToolCall(event) {
  const name = event.toolName;
  const input = event.input || {};
  if (name === "write" || name === "edit") {
    const p = input.path || input.file;
    if (p) gateLog("debug", `post-edit reindex: ${p}`);
  }
  // Bash sift: intercept non-trivial bash commands and pipe through `reliary wrap`.
  // Compressed output replaces the raw result — LLM never sees verbose tool output.
  // Toggle: RELIARY_SIFT_BASH=1 (default OFF for safety).
  if (SIFT_BASH && name === "bash" && RELIARY_BIN) {
    const cmd = input.command || "";
    if (!cmd || cmd.length < 20) return; // skip trivial commands
    // V74: only wrap commands the compressor knows how to handle. Wrapping
    // everything (vim, ssh, top, watch) broke interactive commands: their TTY
    // is replaced by a pipe. Same program list as the Claude hook.
    const prog = cmd.trim().split(/\s+/)[0].replace(/^.*\//, "");
    const REWRITE = new Set(["git","cargo","pytest","npm","yarn","pnpm","ls","grep","rg",
      "docker","kubectl","make","go","mvn","gradle","pip","ruff","mypy","tsc","eslint",
      "jest","vitest","dotnet","composer","bundle"]);
    if (!REWRITE.has(prog)) return;
    // Shell chaining would break the single -c wrapping — leave as-is.
    if (/[|&;<>()`$]/.test(cmd)) return;
    // V61: spawnSync (not execFileSync) — execFileSync THROWS on non-zero
    // exit and LOSES stdout (grep -q no-match = exit 1 with useful stdout),
    // and the catch let Pi re-execute the command (double execution of
    // side-effect commands). spawnSync captures stdout/stderr on any exit
    // code, and we always block + return the result so Pi never re-runs.
    const result = spawnSync(RELIARY_BIN, ["wrap", "bash", "-c", cmd], {
      encoding: "utf-8", timeout: 120000, maxBuffer: 10 * 1024 * 1024
    });
    if (result.error) {
      gateLog("debug", `sift spawn error: ${result.error.message ? result.error.message.slice(0, 60) : "error"}`);
      // V74: a timeout means the command already RAN (spawnSync killed it
      // mid-flight). Blocking prevents Pi from re-executing a side-effecting
      // command. Only a genuinely-missing binary falls through to Pi.
      if (result.error.code === "ETIMEDOUT") {
        const partial = (result.stdout || "") + (result.stderr || "");
        return { block: true, reason: partial.trim() || "(command timed out after 120s)" };
      }
      return; // binary missing — let Pi run normally
    }
    if (result.status === 0) {
      gateLog("debug", `sift: ${cmd.slice(0, 40)} → ${result.stdout.length} chars`);
      return { block: true, reason: result.stdout };
    }
    // Non-zero exit: wrap ran the command; return its (possibly useful)
    // stdout/stderr to the LLM. Never let Pi re-execute.
    const out = (result.stdout || result.stderr || "").trim();
    gateLog("debug", `sift exit ${result.status}: ${out.slice(0, 60)}`);
    return { block: true, reason: out || `(exit ${result.status})` };
  }
  // Code-discovery blocking was removed: a gate that blocks the agent's own
  // read/grep tools fights the model and advertises tool names the default
  // menu does not expose. The reminder (session-reminder.sh for Claude Code)
  // and the tool descriptions carry that guidance instead.
}

// Hook: tool_result — reindex the touched file once the edit has actually landed.
// Also regenerate pack if enabled (opt-in, slow).
function handleToolResult(event) {
  const name = event.toolName;
  const input = event.input || {};
  if (name === "write" || name === "edit") {
    const f = input.path || input.file;
    triggerReindex(f);
    triggerPackRegen(f);
  }
}

// Hook: before_provider_request — turn counting for diagnostics only. No mutation.
function handleBeforeProviderRequest(event) {
  const payload = event.payload;
  if (!payload || !Array.isArray(payload.messages)) return payload;
  let turnCount = 0;
  for (const m of payload.messages) { if (m.role === "user") turnCount++; }
  if (turnCount > sessionTurns) {
    gateLog("info", `turn ${turnCount}`);
    sessionTurns = turnCount;
  }
  return payload;
}

module.exports = function (pi) {
  pi.on("tool_call", handleToolCall);
  pi.on("tool_result", handleToolResult);
  pi.on("before_provider_request", handleBeforeProviderRequest);
};

// ── selftest ──────────────────────────────────────────────────────────────
// Run as `node gate.js --selftest <file>` with RELIARY_BIN_PATH set and the
// file inside an indexed project, to exercise binary discovery and the
// reindex path without a live Pi session. Exits 0 on success, 1 on failure.
// This is the CI seam — runtime behaviour above is unchanged.
if (require.main === module && process.argv.includes("--selftest")) {
  const target = process.argv[process.argv.indexOf("--selftest") + 1];
  const report = { discovery: null, target: target || null, reindex: false, reason: null };
  if (!RELIARY_BIN) {
    report.reason = "binary discovery failed";
  } else if (!target) {
    report.reason = "no target file passed to --selftest";
  } else {
    report.discovery = RELIARY_BIN;
    try {
      const r = spawnSync(RELIARY_BIN, ["reindex-file", target], {
        encoding: "utf-8", timeout: 10000,
      });
      report.reindex = r.status === 0;
      if (!report.reindex) {
        report.reason = (r.stderr || "").slice(0, 200) || `exit ${r.status}`;
      }
    } catch (e) {
      report.reason = e.message;
    }
  }
  console.log(JSON.stringify(report));
  process.exit(report.discovery && report.reindex ? 0 : 1);
}