Gossip
======
 
Chr. Clemens Lahme
 
2026-07-05
 
Table of Contents
-----------------
  1. Purpose
  2. System Requirements
  3. License
  4. Download
  5. Configure
  6. Build
  7. Project File Structure
  8. Model CSV Format
    1. OpenRouter
    2. llama.cpp
    3. pi.dev
  9. Glossary
  10. Nomenclature
  11. Base Utilities
    1. datetime
    2. today
    3. Project Location
    4. duration
    5. question
  12. OpenRouter
    1. Introduction
    2. Configuration
    3. Workflow Overview
    4. Usage Count
    5. Print Answer
    6. Request and Response
    7. The 'ask' Command
  13. Questions
  14. llama.cpp
  15. Downloading a Minimal GGUF Model
  16. Llama Text Output To Gossip Text Format
  17. Invoking llama.cpp Locally
    1. Prerequisites
    2. Usage
    3. Examples
    4. Behaviour
    5. llama Script
  18. Local llama.cpp Server
  19. The llama.cpp Client
  20. llama_call
  21. Understanding Pi Dev
  22. Multi Turn pi.dev Interactions
  23. Interaction With Pi Dev
  24. pi.dev Models Storage
  25. pi.dev OpenRouter Models
  26. Configured OpenRouter Models
  27. Main Gossip Script
  28. Print Answer
  29. Print Question
  30. Tags
  31. Pi Harness Prompt
  32. ToDo
  33. Links
 
1. Purpose
----------
 
Gossip is a Unix command-line frontend for Large Language Models. It stores every
interaction—requests as plain text, responses as structured JSON—locally in a
log-based database (`gossip/db/txt` and `gossip/db/json`) for permanent,
offline reference.
 
It supports three inference backends:
 
*   OpenRouter (Cloud)
    The most practical default. Requires an API key and internet access. Provides
    instant access to a vast catalog of state-of-the-art models (Kimi 3,
    GLM 5.2, MiniMax 3, Nemotron 3 Ultra, etc.) with zero local compute
    requirements.
 
*   llama.cpp (Local)
    Runs models entirely offline on your hardware. Gossip can automatically
    fetch and build 'llama.cpp' for you (requires internet for the initial build
    and model download).
    *   No GPU required: Runs efficiently on CPU.
    *   Modest RAM is sufficient: With 64 GB RAM you can comfortably run
        high-capability models like Qwen 3 35B with surprising results,
        especially for coding.
    *   Total privacy: Data never leaves the machine.
 
*   pi.dev (Agent / Delegation)
    A specialized workflow where Gossip delegates the request to a locally
    running `pi` agent (pi.dev). The agent drives the conversation using its own
    configuration (e.g. again OpenRouter). Gossip captures the resulting session,
    storing the agent's final answer as text alongside the other native logs.
 
Choose OpenRouter for speed, model variety, and zero setup.
 
Choose llama.cpp for privacy, offline work, cost control, and surprisingly
strong performance on standard workstation hardware (64 GB+ RAM).
 
Use pi.dev when you want an autonomous agent to handle multi-step tasks
using your existing `pi` configuration.
 
Or choose all three - because you can.
 
2. System Requirements
----------------------
 
Mandatory
 
* Linux.
* Ruby.
* Both dash and bash shells must be available.
* git.
* make.
* curl
 
Included
 
* Rlib Ruby library.
* lp, a literate programming tool written in Ruby.
 
Either
 
OpenRouter
* An OpenRouter key.
* jq.
* Internet access.
 
llama.cpp
* cmake.
* g++.
* wget in order to download a model in GGUF format.
* At least one large language model downloaded in GGUF format (e.g. from
  Hugging Face).
 
pi.dev
 
3. License
----------
 
Gossip is licensed under the GNU Public License (GPL) version 2, see:
 
file://COPYING.txt
 
4. Download
-----------
 
The home page of this project is at: http://techinvest.li/gossip/
 
You can download the source code with the following git command:
 
git clone https://techinvest.li/git/gossip.git
 
5. Configure
------------
 
The configure checks if all system requirements are met.
 
Run it with the following command on the command line in order to find out if
anything is missing to use Gossip:
 
./configure
 
Here is the script. It got already generated and checked into this project, so
you can run it without getting into circular dependencies.
 
cat > ./configure <<EOT
#!/bin/sh
# Do not edit this file, as it gets automatically created by lp.
 
# configure for Gossip
set -u
 
ROOT=$(CDPATH= cd "$(dirname "$0")" && pwd)
 
MANDATORY_MISSING=0
OPTIONAL_MISSING=0
 
ok()   { printf '  [ ok ] %s\n' "$*"; }
warn() { printf '  [ ?? ] %s\n' "$*"; }
fail() { printf '  [FAIL] %s\n' "$*"; }
 
echo
echo "Gossip configure"
echo "================"
echo
echo "Project root: $ROOT"
echo
 
# ---------------------------------------------------------------------------
# Mandatory requirements
# ---------------------------------------------------------------------------
echo "Mandatory requirements"
echo "----------------------"
 
if [ "$(uname -s)" = "Linux" ]; then
  ok "Linux detected"
else
  fail "Linux not detected (found: $(uname -s))"
  MANDATORY_MISSING=1
fi
 
for shell in dash bash; do
  if command -v "$shell" >/dev/null 2>&1; then
      ok "$shell shell found: $(command -v "$shell")"
  else
      fail "$shell shell not found"
      MANDATORY_MISSING=1
  fi
done
 
if command -v git >/dev/null 2>&1; then
  ok "git found: $(command -v git)"
else
  fail "git not found"
  MANDATORY_MISSING=1
fi
 
if command -v make >/dev/null 2>&1; then
  ok "make found: $(command -v make)"
else
  fail "make not found"
  MANDATORY_MISSING=1
fi
 
if command -v ruby >/dev/null 2>&1; then
  ok "Ruby found: $(ruby -v 2>&1 | sed -n '1p')"
else
  fail "Ruby not found"
  MANDATORY_MISSING=1
fi
 
if command -v curl >/dev/null 2>&1; then
  ok "curl found: $(command -v curl)"
else
  fail "curl not found"
  MANDATORY_MISSING=1
fi
 
# ---------------------------------------------------------------------------
# Optional: OpenRouter dependencies
# ---------------------------------------------------------------------------
echo
echo "Optional: OpenRouter dependencies"
echo "---------------------------------"
 
if command -v jq >/dev/null 2>&1; then
  ok "jq found: $(command -v jq)"
else
  warn "jq not found (OpenRouter backend will be unavailable)"
  OPTIONAL_MISSING=1
fi
 
if [ -f "$ROOT/etc/openrouter.rc" ]; then
  if LC_ALL=C grep -E -v '^[[:space:]]*(#|$)' "$ROOT/etc/openrouter.rc" \
     2>/dev/null | grep . >/dev/null 2>&1
  then
      ok "OpenRouter key file present: etc/openrouter.rc"
  else
      warn "etc/openrouter.rc is empty or contains only comments"
      OPTIONAL_MISSING=1
  fi
else
  warn "etc/openrouter.rc not found (OpenRouter backend will be unavailable)"
  OPTIONAL_MISSING=1
fi
 
# Internet access check using Ruby, since Ruby is mandatory.
if command -v ruby >/dev/null 2>&1; then
  if ruby -rsocket -rtimeout -e \
      'begin; Timeout.timeout(10) { Socket.tcp("example.com", 443) { |s| } }; rescue StandardError; exit 1; end' \
      >/dev/null 2>&1
  then
      ok "Internet access (TCP connection to example.com:443)"
  else
      warn "Internet access could not be verified"
      OPTIONAL_MISSING=1
  fi
else
  fail "Cannot check internet because Ruby is missing"
  MANDATORY_MISSING=1
fi
 
# ---------------------------------------------------------------------------
# Optional: llama.cpp dependencies
# ---------------------------------------------------------------------------
echo
echo "Optional: llama.cpp dependencies"
echo "--------------------------------"
 
if command -v cmake >/dev/null 2>&1; then
  ok "cmake found: $(command -v cmake)"
else
  warn "cmake not found (llama.cpp backend will be unavailable)"
  OPTIONAL_MISSING=1
fi
 
if command -v g++ >/dev/null 2>&1; then
  ok "g++ found: $(command -v g++)"
else
  warn "g++ not found (llama.cpp backend will be unavailable)"
  OPTIONAL_MISSING=1
fi
 
if command -v wget >/dev/null 2>&1; then
  ok "wget found: $(command -v wget)"
else
  warn "wget not found (llama.cpp will not easily get a GGUF model file)"
  OPTIONAL_MISSING=1
fi
 
GGUF_MODEL="${GGUF_MODEL:-}"
found_model=""
 
if [ -n "$GGUF_MODEL" ]; then
  if [ -f "$GGUF_MODEL" ]; then
      ok "GGUF model found: $GGUF_MODEL"
      found_model="$GGUF_MODEL"
  else
      warn "GGUF_MODEL is set but file not found: $GGUF_MODEL"
      OPTIONAL_MISSING=1
  fi
else
  if command -v find >/dev/null 2>&1; then
      for dir in "$ROOT/db/gguf" "$ROOT/opt/llama.cpp/models"; do
          if [ -d "$dir" ]; then
              candidate=$(LC_ALL=C find "$dir" -type f -name '*.gguf' 2>/dev/null \
                          | sed -n '1p' 2>/dev/null)
              if [ -n "$candidate" ]; then
                  found_model="$candidate"
                  break
              fi
          fi
      done
 
      if [ -n "$found_model" ]; then
          ok "GGUF model found: $found_model"
      else
          warn "No .gguf model found in db/gguf."
          OPTIONAL_MISSING=1
      fi
  else
      warn "find not found; cannot search for GGUF models"
      OPTIONAL_MISSING=1
  fi
fi
 
# ---------------------------------------------------------------------------
# Optional: pi.dev dependencies
# ---------------------------------------------------------------------------
echo
echo "Optional: pi.dev dependencies"
echo "-----------------------------"
 
if command -v pi >/dev/null 2>&1; then
  ok "pi.dev agent found: $(command -v pi)"
else
  warn "pi command not found (pi.dev backend will be unavailable)"
  OPTIONAL_MISSING=1
fi
 
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo
if [ "$MANDATORY_MISSING" -eq 1 ]; then
  echo "Gossip configure: FAILED"
  echo "Mandatory requirements are missing. Please install them and re-run configure."
  exit 1
fi
 
if [ "$OPTIONAL_MISSING" -eq 1 ]; then
  echo "Gossip configure: done (with optional dependency warnings)."
else
  echo "Gossip configure: done."
fi
 
exit 0
 
# End of: configure
EOT
 
6. Build
--------
 
Gossip is distributed as a literate program: the executable scripts are not
checked in directly but are generated from this document by the included
'lp' tool. To build everything, including the bin/gossip.rb command-line
frontend, run:
 
make
 
7. Project File Structure
-------------------------
 
Here is part of the project directory structure:
 
gossip
+-bin
| +-gossip.rb
+-build
+-db
| +-csv
| | +-model_20260726_054647.csv
| | +-tags.csv
| +-json
| | +-response_20260726_054647.json
| +-txt
|   +-answer_20260724_121431.txt
|   +-answer_20260726_054647.txt
|   +-question_20260726_054647.txt
+-doc
| +-index.txt
+-lib
| +-rlib.rb
+-log
+-opt
| +-llama.cpp
+-tmp
 
db/csv contains model_YYYYMMDD_HHMMSS.csv files and db/txt contains
question_YYYYMMDD_HHMMSS.txt files with question answer text content, to and
from local and cloud LLMs models. The model file contains meta information
about the model used.
 
8. Model CSV Format
-------------------
 
The model record is a one-line CSV file stored at
'db/csv/model_<datetime>.csv'. It records which backend and model answered a
given question. The '<datetime>' portion of the filename ('YYYYMMDD_HHMMSS') is
the primary key that links the model record to its corresponding question,
answer, and response file.
 
There is no header line in the file - the single data line is the entire file
content.
 
Format by Backend
 
8.1. OpenRouter
'''''''''''''''
 
openrouter,<model_id>
 
Example:
 
openrouter,deepseek/deepseek-v4-pro
 
8.2. llama.cpp
''''''''''''''
 
llama.cpp,<model_basename>,version=<llama_cpp_version>,seed=<seed>
 
Example:
 
llama.cpp,Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf,version=10075,seed=1
 
8.3. pi.dev
'''''''''''
 
pi.dev,<model_id>,reasoning=<thinking_level>
 
Example:
 
pi.dev,inclusionai/ling-3.0-flash:free,reasoning=high
 
9. Glossary
-----------
 
Large language model (llm)
 
A neural network trained on vast amounts of text data that can understand and
generate human-like language. In Gossip, LLMs are accessed either remotely via
OpenRouter (cloud models like GPT, Gemini, Qwen, DeepSeek, etc.) or locally via
llama.cpp (GGUF-format models such as Llama 3, Qwen 3.6, etc.). The model
used for each question answer pair is recorded in 'db/csv/model_<datetime>.csv'.
 
Inference
 
The process of running a prompt through an LLM to generate a response. In
Gossip, inference happens in two modes:
 
a) cloud inference — an HTTPS POST to
   'https://openrouter.ai/api/v1/chat/completions' with a JSON payload
   containing the model ID, messages, reasoning settings, temperature, and
   provider routing;
 
b) local inference — a direct invocation of 'llama-cli' (from llama.cpp) with
   flags for single-turn, non-interactive, deterministic ('--temp 0.0 --seed
   1') output.
 
Both paths store the raw response and a cleaned-up version under
'db/txt/answer_<datetime>.txt'.
 
Prompt
 
The user-supplied input text sent to the LLM. In Gossip, prompts originate from
three sources:
 
a) typed on the command line ('bin/llama <model> "What is POSIX?"'),
b) read from an arbitrary file ('bin/llama <model> /path/to/question.txt'), or
c) piped via stdin ('echo "prompt" | bin/llama <model>').
 
Every prompt is immediately persisted as 'db/txt/question_<datetime>.txt'
before inference begins.
 
Request
 
The complete HTTP payload sent to an LLM provider. In Gossip this is the JSON
document POSTed to 'https://openrouter.ai/api/v1/chat/completions' (cloud) or
the command-line arguments passed to 'llama-cli' (local). It contains the
model identifier, the conversation messages, and inference parameters such as
temperature, reasoning flags, and provider routing.
 
Response
 
The raw byte stream returned by the LLM provider. For OpenRouter this is the
HTTP response body (JSON) including the generated message, usage statistics,
and any provider-specific metadata. For llama.cpp it is the combined stdout/stderr
output of the 'llama-cli' process, including the answer, thinking traces, and
performance timings, thought not in JSON but normal text format.
 
Reply
 
Synonym for Response used when emphasising the conversational view: the
assistant's turn in the dialogue. In Gossip the raw reply is first saved
verbatim in a raw response file and then transformed into a converted reply -
also called answer file - with cleaned text with reasoning wrapped in '<think>
... </think>' tags.
 
JSON
 
JavaScript Object Notation — the wire format used for all OpenRouter API
requests and responses. Gossip constructs request payloads safely with 'jq'
('--slurpfile', '--argjson', '-Rs') to avoid shell-escaping issues. Responses
are saved verbatim to 'db/json/response_<datetime>.json' and later pretty-printed
for reading via 'bin/or_print.rb' into
'db/txt/answer_<datetime>.txt'. Pi.dev sessions are also stored as JSONL
(newline-delimited JSON) under '~/.pi/agent/sessions/'.
 
CSV
 
Comma-Separated Values — the lightweight tabular format Gossip uses for
metadata indexes. Two CSV families exist:
 
1. 'db/csv/model_<datetime>.csv' — one line per Q/A pair recording the backend
   ('gossip,openrouter' or 'gossip,llama.cpp-<version>'), the model identifier,
   and an optional reasoning flag;
 
2. 'db/csv/tags.csv' — a many-to-many tag index where each line is
   '<datetime>,<tag1>,<tag2>,...' enabling tag-based lookup via 'bin/tags.rb'.
 
OpenRouter
 
A unified API gateway that proxies requests to dozens of third-party LLM
providers (Kimi, DeepSeek, Qwen, GLM, MiniMax, etc.). Gossip uses OpenRouter
for all cloud inference; the API key is stored in 'etc/openrouter.rc' and
requests are sent to 'https://openrouter.ai/api/v1/chat/completions'. A user
account there is free and so far there have been always free models available,
although they change over time, in order to start experiment. What is needed is
the API key setup for Gossip to use in the etc/openrouter.rc file.
 
llama.cpp
 
An open source C++ inference engine for GGUF-format models that runs
entirely on local hardware (CPU and or GPU). Gossip invokes its build
'llama-cli' executable for offline and private inference. The built binary
lives under 'gossip/opt/llama.cpp'.
 
pi.dev
 
An open-source, terminal-native AI coding agent ('@earendil-works/pi-coding-agent')
that runs multi-turn, tool-using sessions. Gossip can import pi.dev session
logs ('~/.pi/agent/sessions/*.jsonl') to extract user/assistant exchanges and
store them as first-class question/answer pairs in its own database.
 
10. Nomenclature
----------------
 
In the Gossip codebase and documentation the following terms have specific,
narrower meanings than their general definitions above:
 
question
 
A prompt that has been persisted to 'db/txt/question_<datetime>.txt' before
inference starts. Every interaction-whether typed on the command line, read
from a file, piped via stdin, or imported from a pi.dev session-becomes a
question file. The timestamp in the filename is the canonical primary key for
the entire Q/A pair. The content of the question file can also consist of an
instruction, document, or whole conversation.
 
answer
 
The converted reply saved to a text or markdown file
answer_<datetime>.txt in location db/txt. It is the
human-readable text shown to the user: the model's final answer first, followed
by any reasoning/thinking content wrapped in '<think>...</think>' markers. The
raw API/CLI output is not shown directly.
 
model record
 
A one-line CSV file 'db/csv/model_<datetime>.csv' recording the backend
('gossip,openrouter' or 'gossip,llama.cpp-<version>'), the model identifier,
and routing/reasoning flags. It is the authoritative source for "which model
answered this question".
 
session (pi.dev sense)
 
A pi.dev JSONL log file under '~/.pi/agent/sessions/'. Gossip's
'bin/pi_sessions.rb' reads these files, extracts 'message' events with 'role:
user' and 'role: assistant', and converts each pair into a Gossip
question/answer pair with matching timestamps.
 
datetime
 
The 15-character string 'YYYYMMDD_HHMMSS' produced by 'bin/datetime'.  It is
used as the sole identifier in all database filenames
('question_<datetime>.txt', 'model_<datetime>.csv', 'tags.csv' rows)
guaranteeing lexical sort order equals chronological order.
 
tag
 
A free-form label stored in 'db/csv/tags.csv' as
'<datetime>,<tag1>,<tag2>,...'. Tags enable fast thematic lookup via 'bin/tags.rb
<tag>' without full-text search.
 
provider (OpenRouter sense)
 
The upstream inference provider selected via the 'provider.only' field in the
OpenRouter request (e.g. 'google-ai-studio', 'deepseek', 'alibaba',
'together'). Gossip hard-codes a mapping from model ID to preferred provider in
'bin/or_ask.sh'.
 
11. Base Utilities
------------------
 
The document is written in a bottom up fashion, code that will be invoked later
is mentioned first. Here is some boiler plate code that might be used
throughout the project later on.
 
11.1. datetime
''''''''''''''
 
The output of the datetime script will be used all over the place as an
identifier and in file names to have an order of question answer pairs and
other meta data.
 
cat > ./bin/datetime <<EOT
#! /bin/dash
# Do not edit this file, as it gets automatically generated by lp.
 
date '+%Y%m%d_%H%M%S'
EOT
 
11.2. today
'''''''''''
 
The 'today' script prints the current date in ISO YYYY-MM-DD format. It is used
in other Gossip shell scripts.
 
cat > ./bin/today <<EOT
#! /bin/dash
# Do not edit this file, as it gets automatically generated by lp.
 
date '+%Y-%m-%d'
EOT
 
11.3. Project Location
''''''''''''''''''''''
 
We want to be able to invoke tools from gossip from any location, so inside the
project it is handy to identify your own location.
 
cat > lib/apphome.sh <<EOT
if [ "$APPHOME" = "" ]
then
  # try to locate application home directory
  ## resolve links - $0 may be a link to the app home
  PRG=$0
  progname=`basename $0`
  while [ -h "$PRG" ]
  do
    ls=`ls -ld "$PRG"`
    link=`expr "$ls" : '.*-> \(.*\)$'`
    if expr "$link" : '/.*' > /dev/null
    then
      PRG="$link"
    else
      PRG="`dirname $PRG`/$link"
    fi
  done
  export APPHOME=`dirname "$PRG"`/..
  export APPHOME=$(cd $APPHOME >/dev/null; pwd)
fi
EOT
 
This is actually just a code snippet for use in other scripts. We need to copy
the source into other shell scripts, otherwise we would already know the
projects home location.
 
11.4. duration
''''''''''''''
 
The duration script is not inlined here but copied from the Rlib project and
shows in seconds or minutes how long a task has taken.
 
11.5. question
''''''''''''''
 
The 'bin/question' script creates a new empty question file
'db/txt/question_<datetime>.txt' and prints its absolute path. It is used by
'bin/ask' (and can be used directly) to prepare a question file before opening
an editor or writing a prompt. It can also be used to set up a Gossip
question/answer database inside another project - the database files just need
a 'db/txt' directory to live in.
 
The script resolves the project root via the same 'APPHOME' logic used in
'lib/apphome.sh', so it works from any working directory. An optional
'YYYYMMDD_HHMMSS' timestamp can be passed as the first argument; otherwise the
current time is used.
 
cat > bin/question <<EOT
#! /bin/dash
# Do not edit this file, as it gets automatically generated by lp.
 
if [ "$APPHOME" = "" ]
then
  # try to locate application home directory
  ## resolve links - $0 may be a link to the app home
  PRG=$0
  progname=`basename $0`
  while [ -h "$PRG" ]
  do
    ls=`ls -ld "$PRG"`
    link=`expr "$ls" : '.*-> \(.*\)$'`
    if expr "$link" : '/.*' > /dev/null
    then
      PRG="$link"
    else
      PRG="`dirname $PRG`/$link"
    fi
  done
  export APPHOME=`dirname "$PRG"`/..
  export APPHOME=$(cd $APPHOME >/dev/null; pwd)
fi
 
DATETIME=$(date '+%Y%m%d_%H%M%S')
if [ "$1" != "" ]
then
  DATETIME="$1"
fi
 
if [ -d "${PWD}/db/txt" ]
then
  QUESTION_DIR="${PWD}/db/txt"
else
  QUESTION_DIR="${APPHOME}/db/txt"
fi
 
FILENAME="${QUESTION_DIR}/question_${DATETIME}.txt"
 
touch "$FILENAME"
echo "$FILENAME"
 
# End of: question
EOT
 
Usage:
 
# Create a new question file with current timestamp.
$ ./bin/question
/path/to/gossip/db/txt/question_20260705_123456.txt
 
# Create a question file with a specific timestamp (e.g. for reproducibility).
$ ./bin/question 20260705_123456
/path/to/gossip/db/txt/question_20260705_123456.txt
 
The returned path can be passed to an editor:
 
qf=$(./bin/question)
$EDITOR $qf
 
12. OpenRouter
--------------
 
12.1. Introduction
''''''''''''''''''
 
OpenRouter is Gossip's cloud inference backend. It provides a single, unified
API endpoint ('https://openrouter.ai/api/v1/chat/completions') that routes
requests to dozens of upstream model providers-Google, DeepSeek, Moonshot,
NVIDIA, Z.ai, Alibaba, MiniMax, and many others. This means you get immediate
access to a constantly evolving catalog of state-of-the-art models (Kimi 3,
GLM 5.2, MiniMax 3, Nemotron 3 Ultra, Qwen 3.7, etc.) without managing
multiple API keys, SDKs, or provider-specific quirks.
 
12.2. Configuration
'''''''''''''''''''
 
You need an OpenRouter account - sign up at https://openrouter.ai/ is free and
the (ever changing) free models get you quite far.
 
What you need is an API key. After you created your account, you will get one.
 
Create 'etc/openrouter.rc' in the Gossip project root and put a line like this:
 
OPENROUTER_API_KEY=sk-or-v1-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
 
Replace the string content with your API key.
 
Make sure only you can read it:
 
chmod 600 etc/openrouter.rc
 
The file is sourced by 'or_ask.sh' and must export
'OPENROUTER_API_KEY'. Keep it out of version control (it is already in
'.gitignore').
 
For identification of useful large language models at OpenRouter have a look at
the Links section further down in this document.
 
12.3. Workflow Overview
'''''''''''''''''''''''
 
Every OpenRouter interaction follows the same lifecycle:
 
1.  Question capture - User prompt is written to
    'db/txt/question_<datetime>.txt' (via 'bin/ask' or manually).
2.  Payload construction - 'or_ask.sh' reads the question,
    builds a JSON request with 'jq' (safe against injection, quoting, and
    encoding issues), and writes it to a temporary file.
3.  HTTP request - 'curl' POSTs the payload to OpenRouter with the API key.
4.  Response logging - Raw response (headers + body) is saved to
    'db/json/response_<datetime>.json'.
5.  Model metadata - A one-line CSV record is written to
    'db/csv/model_<datetime>.csv' recording backend, model ID, and routing.
6.  Answer extraction - 'or_print.rb' parses the JSON,
    extracts the assistant message and optional reasoning, cleans formatting,
    and writes the human-readable answer to 'db/txt/answer_<datetime>.txt'.
7.  Display - The answer is shown in 'less' (or printed to stdout by
    'bin/ask').
 
All files share the same '<datetime>' prefix ('YYYYMMDD_HHMMSS'), so the
question, model record, raw response, and cleaned answer are trivially
correlated.
 
12.4. Usage Count
'''''''''''''''''
 
First a script to see how many times the user has invoked openrouter already
today.
 
cat > ./bin/or_count.sh <<EOT
#! /bin/dash
# Do not edit this file, as it gets automatically generated by lp.
 
# This script counts how many times openrouter has been invoked today.
 
# An optional first argument selects the database directory to inspect;
# it defaults to the gossip database "db". bin/or_ask.sh
# passes DB_DIR here so that question/answer databases inside other
# projects are counted as well (same convention as bin/question).
 
DB_DIR=db
if [ "$1" != "" ]
then
  DB_DIR="$1"
fi
 
DATETIME=$(./bin/datetime)
TODAY=$(echo $DATETIME | perl -pe 's/_.*$//;')
grep -E -i 'openrouter,' "${DB_DIR}"/csv/model_${TODAY}_??????.csv 2>/dev/null | wc -l
 
# End of: or_count.sh
EOT
 
12.5. Print Answer
''''''''''''''''''
 
Following comes the Ruby script 'or_print.rb' that extracts the
'reasoning' (thinking) part and the main 'content' (answer) from the OpenRouter
JSON reply, printing them clearly to standard output:
 
cat > ./bin/or_print.rb <<EOT
#! /usr/bin/env ruby
# coding: utf-8
# Do not edit this file, as it gets automatically generated by lp.
 
require "json"
 
answer_filename = ARGV[ 0 ]
 
if answer_filename.nil? || answer_filename.empty?
  puts "Usage: #{$0} <json_filename>"
  exit 1
end
 
begin
  content = File.read( answer_filename )
  json_data = JSON.parse( content )
rescue JSON::ParserError => e
  puts "Error parsing JSON: #{answer_filename}"
  exit 2
rescue Errno::ENOENT
  puts "File not found: #{answer_filename}"
  exit 3
end
 
# ============================================================
# MODEL & COST INFORMATION
# ============================================================
puts "=" * 60
puts "MODEL & USAGE"
puts "=" * 60
puts
 
model       = json_data[ "model" ]
provider    = json_data[ "provider" ]
system_fp   = json_data[ "system_fingerprint" ]
created     = json_data[ "created" ]
id          = json_data[ "id" ]
 
puts "Model:           #{model}"           if model
puts "Provider:        #{provider}"        if provider
puts "System Fingerprint: #{system_fp}"    if system_fp
puts "Request ID:      #{id}"              if id
if created
  time_str = Time.at(created).strftime("%Y-%m-%d %H:%M:%S %Z")
  puts "Created:         #{time_str}"
end
 
usage = json_data[ "usage" ]
if usage
  puts
  puts "Token Usage:"
  puts "  Prompt tokens:     #{usage[ 'prompt_tokens' ]}"           if usage[ 'prompt_tokens' ]
  puts "  Completion tokens: #{usage[ 'completion_tokens' ]}"       if usage[ 'completion_tokens' ]
  puts "  Total tokens:      #{usage[ 'total_tokens' ]}"            if usage[ 'total_tokens' ]
 
  # Reasoning tokens (if available)
  if usage[ 'completion_tokens_details' ] && usage[ 'completion_tokens_details' ][ 'reasoning_tokens' ]
  puts "  Reasoning tokens:  #{usage[ 'completion_tokens_details' ][ 'reasoning_tokens' ]}"
  end
 
  # Cached tokens (if available)
  if usage[ 'prompt_tokens_details' ] && usage[ 'prompt_tokens_details' ][ 'cached_tokens' ]
  puts "  Cached tokens:     #{usage[ 'prompt_tokens_details' ][ 'cached_tokens' ]}"
  end
 
  puts
  puts "Cost:"
  cost = usage[ "cost" ]
  if cost
  printf "  Total cost:        $%.6f\n", cost
  else
  puts "  Total cost:        N/A"
  end
 
  if usage[ "cost_details" ]
  cd = usage[ "cost_details" ]
  printf "  Upstream inference: $%.6f\n", cd[ "upstream_inference_cost" ]      if cd[ "upstream_inference_cost" ]
  printf "  Upstream prompt:    $%.6f\n", cd[ "upstream_inference_prompt_cost" ] if cd[ "upstream_inference_prompt_cost" ]
  printf "  Upstream completion:$%.6f\n", cd[ "upstream_inference_completions_cost" ] if cd[ "upstream_inference_completions_cost" ]
  end
 
  puts "  BYOK:              #{usage[ 'is_byok' ] ? 'yes' : 'no'}" if usage.key?( 'is_byok' )
end
 
puts
 
# ============================================================
# EXTRACT CHOICE
# ============================================================
choices = json_data[ "choices" ]
if choices.nil? || choices.empty?
  puts "No choices found in JSON."
  exit 4
end
 
message = choices[ 0 ][ "message" ]
if message.nil?
  puts "No message found in choices."
  exit 5
end
 
# ============================================================
# REASONING (THINKING)
# ============================================================
reasoning = message[ "reasoning" ]
if reasoning && !reasoning.empty?
  puts "=" * 60
  puts "THINKING / REASONING"
  puts "=" * 60
  puts
  puts reasoning.gsub( /\n\n+/, "\n\n" ).strip
  puts
end
 
# ============================================================
# MAIN CONTENT (ANSWER)
# ============================================================
content_text = message[ "content" ]
if content_text && !content_text.empty?
  puts "=" * 60
  puts "ANSWER"
  puts "=" * 60
  puts
  clean_text = content_text.gsub( /\n\n+/, "\n\n" ).strip
  puts clean_text
  puts
end
 
# ============================================================
# FINISH REASON
# ============================================================
finish_reason = choices[ 0 ][ "finish_reason" ]
native_finish = choices[ 0 ][ "native_finish_reason" ]
if finish_reason || native_finish
  puts "=" * 60
  puts "FINISH INFO"
  puts "=" * 60
  puts
  puts "Finish reason:      #{finish_reason}"      if finish_reason
  puts "Native finish reason: #{native_finish}"    if native_finish
  puts
end
 
# If neither was present
if (reasoning.nil? || reasoning.empty?) && (content_text.nil? || content_text.empty?)
  puts "No reasoning or content found in the message."
  exit 6
end
EOT
 
12.6. Request and Response
''''''''''''''''''''''''''
 
This script builds the JSON request, sends it to OpenRouter, receives the
response, saves it to the local data store, and creates a text file out of it
plus prints the location of this text file out, so the user can find and read
it.
 
cat > ./bin/or_ask.sh <<EOT
#! /bin/bash
# Do not edit this file, as it gets automatically generated by lp.
 
# =============================================================================
# or_ask.sh
# =============================================================================
# PURPOSE:
#   Safely query the OpenRouter API using JSON payloads constructed via 'jq',
#   with timestamped Q&A logging, and provider routing.
#
# ENVIRONMENT:
#   DATETIME   : Timestamp for Q&A files. Auto-generated if unset.
#   DB_DIR     : Database directory holding the txt/csv/json subdirectories.
#                Defaults to "db" (the gossip database). bin/ask sets this
#                to another project's db directory when the question lives
#                there (same convention as bin/question).
#   OPENROUTER_API_KEY : API key sourced from etc/openrouter.rc
#
# INPUT FILES:
#   ${DB_DIR}/txt/question_${DATETIME}.txt   : Raw user prompt (must exist)
#
# OUTPUT FILES:
#   ${DB_DIR}/csv/model_${DATETIME}.csv      : Model identifier & routing metadata
#   ${DB_DIR}/json/response_${DATETIME}.json : Raw API response (JSON + text)
#   ${DB_DIR}/txt/answer_${DATETIME}.txt     : Converted, human readable answer
#
# DEPENDENCIES:
#   - bash (4.0+)
#   - jq (1.6+)
#   - curl
#   - etc/openrouter.rc (must export OPENROUTER_API_KEY)
#   - Helper scripts:
#       ./bin/datetime
#       ./bin/or_count.sh
#       ./bin/or_print.rb
#       ./bin/duration
#
# USAGE:
#   DATETIME=YYYYMMDD_HHMMSS ./bin/or_ask.sh <openrouter_model_id>
#
# ENVIRONMENT:
#   DATETIME   : Timestamp for Q&A files. Auto-generated if unset.
#   OPENROUTER_API_KEY : API key sourced from etc/openrouter.rc
#
# INPUT FILES:
#   db/txt/question_${DATETIME}.txt   : Raw user prompt (must exist)
#
# OUTPUT FILES:
#   db/csv/model_${DATETIME}.csv      : Model identifier & routing metadata
#   db/json/response_${DATETIME}.json : Raw API response (JSON + text)
#
# WORKFLOW:
#   1. Check daily usage count via or_count.sh
#   2. Validate/initialize DATETIME and source API credentials
#   3. Look up provider in db/csv/openrouter_models.csv (falls back to empty if not found)
#   4. Read prompt from question file and safely encode it via jq
#   5. Construct full JSON payload (messages, reasoning, temperature, provider)
#   6. Send request to https://openrouter.ai/api/v1/chat/completions
#   7. Save response & model metadata to timestamped CSV files
#   8. Display answer in 'less' and print execution timing
#
# SAFETY & ROBUSTNESS:
#   - Uses 'jq -Rs' to read prompts: prevents shell injection, handles quotes,
#     newlines, backslashes, and Unicode safely without escaping hacks.
#   - 'set -e' ensures immediate exit on command failure.
#   - Validates question file existence before proceeding.
#   - Provider routing is explicit and extensible; falls back to OpenRouter defaults.
#
# INTEGRATION NOTES:
#   - Expects 'DATETIME' to be set by 'bin/set_datetime.sh' or TUI state manager
#   - Output format matches 'gossip' database schema (question/model/answer are
#     text or CSV formats)
 
reasoning=true
#reasoning=false
#temperature=1.0
temperature=0.0
 
set -e
 
# The database directory can be injected by the caller (bin/ask) to support
# question/answer databases inside other projects (see bin/question).
# It defaults to the gossip database.
if [ "$DB_DIR" = "" ]
then
  DB_DIR=db
fi
#mkdir -p "${DB_DIR}/txt" "${DB_DIR}/csv" "${DB_DIR}/json"
 
printf "Today's question count: "
./bin/or_count.sh "$DB_DIR"
 
if [ "$DATETIME" = "" ]
then
  DATETIME=$(./bin/datetime)
fi
echo "DATETIME=${DATETIME}"
 
# This sets the user key/password.
source etc/openrouter.rc
 
model="$1"
echo "model=${model}"
 
DATETIME_BEGIN=$(./bin/datetime)
 
# Look up the provider from db/csv/openrouter_models.csv.
# Format: model_id,provider
# If no match is found, provider remains empty (falls back to OpenRouter defaults).
# NOTE: this is gossip configuration, so it is always read from the gossip
# database, independent of DB_DIR.
provider=""
while IFS=, read -r csv_model csv_provider; do
  # Skip comment/empty lines
  case "$csv_model" in
    '#'*|'') continue ;;
  esac
  if [ "$model" = "$csv_model" ]; then
    provider="$csv_provider"
    break
  fi
done < "db/csv/openrouter_models.csv"
echo "provider=${provider}"
 
if [ "$model" = "minimax/minimax-m2.7" ]
then
  reasoning=true
fi
if [ "$model" = "minimax/minimax-m3" ]
then
  reasoning=true
fi
echo "reasoning=${reasoning}"
 
QUESTION_FILENAME="${DB_DIR}/txt/question_${DATETIME}.txt"
MODEL_FILENAME="${DB_DIR}/csv/model_${DATETIME}.csv"
RESPONSE_FILENAME="${DB_DIR}/json/response_${DATETIME}.json"
echo "RESPONSE_FILENAME=${RESPONSE_FILENAME}"
ANSWER_FILENAME="${DB_DIR}/txt/answer_${DATETIME}.txt"
test -f $QUESTION_FILENAME || {
  echo "ERROR: now file with a question found: ${QUESTION_FILENAME}"
  exit 2
}
echo "openrouter,${model}" > $MODEL_FILENAME
 
TMPDIR=./tmp
mkdir -p $TMPDIR
 
# Read the question text from the file.
#QUESTION=$(cat "$QUESTION_FILENAME")
 
#curl --silent https://openrouter.ai/api/v1/chat/completions \
#     -H "Content-Type: application/json" \
#     -H "Authorization: Bearer $OPENROUTER_API_KEY" \
#     -d "{ \"model\": \"${model}\", \"messages\": [ { \"role\": \"user\", \"content\": \"${question}\" } ], \"reasoning\": { \"enabled\": ${reasoning} }, \"temperature\": ${temperature}, \"provider\": { \"only\": [ \"${provider}\" ] } }" |& tee db/txt/answer_${DATETIME}.txt
 
# Convert bash true/false to JSON boolean for --argjson.
if [ "$reasoning" = "true" ]; then
  reasoning_json=true
else
  reasoning_json=false
fi
 
# Build the JSON payload with jq.
JSON_FILE="$TMPDIR/openrouter_${DATETIME}.json"
MESSAGE_FILE="$TMPDIR/message_${DATETIME}.json"
 
# -------------------------------------------------------------------------
# Step 1: Build the "messages" array in a separate JSON file.
#         'jq -Rs' reads the question file as a single raw string and
#         wraps it into a JSON array, so all special characters (quotes,
#         newlines, backslashes, ...) are escaped properly. The file is
#         written via shell redirection, so the long content never goes
#         on the command line.
# -------------------------------------------------------------------------
jq -Rs '[{ role: "user", content: . }]' "$QUESTION_FILENAME" > "$MESSAGE_FILE"
echo "MESSAGE_FILE=${MESSAGE_FILE}"
 
# -------------------------------------------------------------------------
# Step 2: Build the full request body.
#         '--slurpfile messages' reads the file produced in step 1 and
#         binds it to '$messages' (wrapped in an outer array by slurpfile,
#         so $messages[0] is our messages array). The remaining parameters
#         are small, so passing them on the command line is safe.
# -------------------------------------------------------------------------
jq -n \
  --arg model "$model" \
  --argjson reasoning "$reasoning_json" \
  --argjson temperature "$temperature" \
  --arg provider "$provider" \
  --slurpfile messages "$MESSAGE_FILE" \
  '{
   model: $model,
   messages: $messages[0],
   reasoning: { enabled: $reasoning },
   temperature: $temperature
   }
   + (if $provider != "" then { provider: { only: [$provider] } } else {} end)' \
   > "$JSON_FILE"
echo "JSON_FILE=${JSON_FILE}"
 
# Send the request using the JSON file.
# Both stdout and stderr are captured into the answer file.
curl --silent https://openrouter.ai/api/v1/chat/completions \
   -H "Content-Type: application/json" \
   -H "Authorization: Bearer $OPENROUTER_API_KEY" \
   -d "@$JSON_FILE" |& tee "$RESPONSE_FILENAME"
 
echo "Response received: the answer has been written to: ${RESPONSE_FILENAME}"
#echo "openrouter,${model}" > $MODEL_FILENAME
 
DATETIME_END=$(./bin/datetime)
echo
echo "DATETIME=${DATETIME}"
#command="./bin/or_print.rb ${RESPONSE_FILENAME} > ${ANSWER_FILENAME}"
#echo $command
#echo
#$command
#cat db/txt/answer_${DATETIME}.txt | perl -pe 's/\\n/\n/g;' | less +G
./bin/or_print.rb $RESPONSE_FILENAME > $ANSWER_FILENAME
echo less "$ANSWER_FILENAME"
 
./bin/duration $DATETIME_BEGIN $DATETIME_END
 
echo "SUCCESS: $0 - $?."
 
# End of: or_ask.sh
EOT
 
12.7. The 'ask' Command
'''''''''''''''''''''''
 
The 'bin/ask' script is the primary user-facing entry point for OpenRouter
(cloud) inference. It handles the complete workflow: creating a timestamped
question file, optionally opening your editor, sending the request, and
displaying the answer.
 
Use the EDITOR environment setting
 
export EDITOR=nano
./bin/ask nvidia/nemotron-3-ultra-550b-a55b:free
# The nano editor will open, write your question, prompt, or instruction, save
# the file and exit the editor.
 
One Liner Prompt
 
bin/ask deepseek/deepseek-v4-pro "Explain inference in LLMs in three sentences."
 
Here noo editor opens; the prompt is written directly to the question file. The
answer is printed immediately after the request completes.
 
Multi-Word Prompt Without Quotes
 
bin/ask deepseek/deepseek-v4-pro What is the capital of France?
 
All arguments after the model ID are joined with spaces to form the prompt and
again the answer gets printed.
 
And here is the script:
 
cat > ./bin/ask <<EOT
#!/bin/bash
# Do not edit this file, as it gets automatically generated by lp.
 
set -e
 
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
 
# Work from the project directory so relative paths in helper scripts work.
#cd "$PROJECT_DIR"
 
print_usage() {
  echo "Usage: $0 <openrouter_model_id> [prompt text...]"
  echo ""
  echo "Interactively asks an OpenRouter model a question."
  echo ""
  echo "If no prompt text is given, opens the editor to write one."
  echo "If prompt text is given, uses it directly."
  echo "If a prompt file is given, reads it as the prompt."
  echo "If a question file (question_<datetime>.txt) is given, reuses it."
  echo ""
  echo "Like bin/question this script supports question/answer databases"
  echo "inside other projects: if the current directory contains a db/txt"
  echo "directory, that project's database is used. If a reused question"
  echo "file lives in another project's db/txt, the model, response and"
  echo "answer files are written to the analog locations next to it."
}
 
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
  print_usage
  exit 0
fi
 
if [ $# -lt 1 ]; then
  print_usage
  exit 1
fi
 
model="$1"
shift
 
REUSE_QUESTION=0
QUESTION_FILE=""
PROMPT_FILE=""
USE_FILE_PROMPT=0
 
# Database selection, same convention as bin/question: a db/txt directory
# in the current working directory makes the current project the database
# location; otherwise the gossip database is used.
if [ -d "${PWD}/db/txt" ]; then
  DB_DIR="$(readlink -f "${PWD}/db")"
else
  DB_DIR="$(readlink -f "${PROJECT_DIR}/db")"
fi
 
# An existing file argument is resolved before changing to the project
# directory, so relative file names work from any location.
if [ $# -eq 1 ] && [ -f "$1" ]; then
  CANON_FILE="$(readlink -f "$1")"
  base="${CANON_FILE##*/}"
 
  # Is this a question_<datetime>.txt file inside a db/txt directory?
  # It may belong to the gossip project or to any other project.
  if [[ "$base" =~ ^question_[0-9]{8}_[0-9]{6}\.txt$ ]] \
   && [ "$(basename "$(dirname "$CANON_FILE")")" = "txt" ] \
   && [ "$(basename "$(dirname "$(dirname "$CANON_FILE")")")" = "db" ]
  then
    REUSE_QUESTION=1
    DATETIME="${base#question_}"
    DATETIME="${DATETIME%.txt}"
    QUESTION_FILE="$CANON_FILE"
    # The question file's location decides where the model, response and
    # answer files are written to (the analog locations next to it).
    DB_DIR="$(dirname "$(dirname "$CANON_FILE")")"
    echo "Reusing existing question file: $QUESTION_FILE"
  else
    # Existing file, but not a question file -> use it as prompt input.
    USE_FILE_PROMPT=1
    PROMPT_FILE="$CANON_FILE"
  fi
fi
 
# Work from the project directory so relative paths in helper scripts work.
cd "$PROJECT_DIR"
 
# Generate datetime and save the question content unless we reuse an
# existing question file.
if [ "$REUSE_QUESTION" -ne 1 ]
then
  DATETIME=$(./bin/datetime)
  QUESTION_FILE="${DB_DIR}/txt/question_${DATETIME}.txt"
 
  if [ "$USE_FILE_PROMPT" -eq 1 ]
  then
    cp "$PROMPT_FILE" "$QUESTION_FILE"
    echo "Saved question to: $QUESTION_FILE (from file $PROMPT_FILE)"
  elif [ $# -gt 0 ]
  then
    # Prompt from command line arguments.
    printf '%s\n' "$*" > "$QUESTION_FILE"
    echo "Saved question to: $QUESTION_FILE"
  else
    # Create an empty question file and open the editor. This mirrors
    # bin/question, but into the database selected above.
    touch "$QUESTION_FILE"
    echo "Created question file: $QUESTION_FILE"
    ${EDITOR:-vi} "$QUESTION_FILE"
  fi
fi
 
# Query OpenRouter. DB_DIR tells or_ask.sh where to write
# the model, response and answer files.
DATETIME="$DATETIME" DB_DIR="$DB_DIR" ./bin/or_ask.sh "$model"
 
# Show model record
echo
echo "Model record:"
cat "${DB_DIR}/csv/model_${DATETIME}.csv"
echo
 
# Show answer
echo "Answer:"
echo
cat "${DB_DIR}/txt/answer_${DATETIME}.txt"
echo "${DB_DIR}/txt/answer_${DATETIME}.txt"
 
# End of: ask
EOT
 
Usage:
 
# Interactive: opens editor for the prompt
bin/ask qwen/qwen3.7-plus
 
# One-liner prompt on the command line
bin/ask qwen/qwen3.7-plus "What is POSIX?"
 
# With a specific model
bin/ask deepseek/deepseek-v3.2 "Explain inference in LLMs."
 
# With existing questions file - overwrites existing answer.
bin/ask deepseek/deepseek-v3.2 ./db/txt/questions_20260727_132051.txt
 
The script:
 
1. Generates a timestamp and creates a question file
   ('db/txt/question_<datetime>.txt') - either empty (opened in '$EDITOR') or
   pre-filled with the command-line prompt.
2. Calls 'or_ask.sh' with the model and the same timestamp so all
   files stay in sync.
3. Prints the model record from 'db/csv/model_<datetime>.csv'.
4. Prints the answer from 'db/txt/answer_<datetime>.txt'.
 
13. Questions
-------------
 
The bin/questions.rb script prints out the number, short date, short time, and
question one liner in chronological order in a table format
 
cat > bin/questions.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'rlib'
require 'stack'
require 'term'
 
term = Term.new
columns = term.cols
 
# -------------------------------------------------------------------------
# Help / usage
# -------------------------------------------------------------------------
def print_usage
  puts <<~USAGE
  Usage: #{$0} [--help|-h]
 
  Lists all recorded questions in chronological order with their timestamps
  and the model that answered them.
 
  Database location (DB_DIR)
  --------------------------
  The script uses the same database discovery convention as bin/ask and
  bin/question:
 
    * If the current working directory contains a db/txt directory,
      that project's database is used. This allows any project that uses
      Gossip as its LLM frontend to have its own isolated question/answer
      history.
 
    * Otherwise, the Gossip project's own database (gossip/db) is used.
 
  This means you can run 'bin/questions.rb' from inside any project that
  has a db/txt subdirectory (created by bin/question or bin/ask) and see
  only that project's Q&A history. Run it from the Gossip root (or any
  directory without db/txt) to see the global Gossip history.
 
  Output format
  -------------
  Each line shows:
    <no>. <YY-MM-DD HH:MM> <model> <first line of question>
USAGE
  exit 0
end
 
if ARGV.include?('--help') || ARGV.include?('-h')
  print_usage
end
 
# -------------------------------------------------------------------------
# Determine the database directory (DB_DIR) using the same convention as
# bin/ask: if the current working directory contains a db/txt directory,
# use that project's database; otherwise use the gossip project's database.
# -------------------------------------------------------------------------
if File.directory?(File.join(Dir.pwd, 'db', 'txt'))
  db_dir = File.realpath(File.join(Dir.pwd, 'db'))
else
  project_dir = File.realpath(File.join(File.dirname(__FILE__), '..'))
  db_dir = File.join(project_dir, 'db')
end
 
question_dir = File.join(db_dir, 'txt')
model_dir    = File.join(db_dir, 'csv')
 
txt_files = Dir.glob(File.join(question_dir, 'question_*.txt'))
txt_files.sort!
 
total = txt_files.length
width = total.to_s.length
 
# First pass: collect model names to determine the maximum width.
model_names = []
txt_files.each do |txt_filename|
  basename = File.basename(txt_filename, ".txt")
  timestamp_str = basename.sub("question_", "")
 
  model_name = ""
  model_csv = File.join(model_dir, "model_#{timestamp_str}.csv")
  if File.exist?(model_csv)
    line = Rlib.readfile(model_csv).strip
    parts = line.split(',')
    backend = parts[0]
    model_id = parts[1] || ""
 
    if backend == "openrouter" || backend == "pi.dev"
      # Remove the lab name before the first '/'.
      model_id = model_id.sub(/^[^\/]+\//, '')
      # Remove optional ':free' suffix at the end.
      model_id = model_id.sub(/:free\z/, '')
      model_name = model_id
    elsif backend == "llama.cpp"
      model_name = model_id
    else
      model_name = model_id
    end
  end
  # Beautify some model name(s).
  model_name.sub!( /-550b-a55b$/, '' )
  model_name.sub!( /Q8_0$/, 'Q8' )
 
  model_names << model_name
end
 
# Determine padding width from the longest model name (minimum 4 for "model").
max_model_len = model_names.map { |n| n.length }.max
max_model_len = [max_model_len, 4].max
 
# Second pass: print the table with the model column.
txt_files.each_with_index do |txt_filename, index|
  # Extract timestamp from filename: question_YYYYMMDD_HHMMSS.txt
  basename = File.basename(txt_filename, ".txt")
  timestamp_str = basename.sub("question_", "")
  # Parse YYYYMMDD_HHMMSS
  if timestamp_str =~ /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/
    year_short = $1[2,2]  # YY
    month = $2
    day = $3
    hour = $4
    minute = $5
    datetime_short = "#{year_short}-#{month}-#{day} #{hour}:#{minute}"
  else
    datetime_short = "??-??-?? ??:??"
  end
 
  number = sprintf( "%#{width}d", index + 1 )
 
  # Pad the model name so all rows align.
  model_name = model_names[index]
  model_padded = sprintf( "%-#{max_model_len}s", model_name )
 
  question = "#{number}. #{datetime_short} #{model_padded} "
  content = Rlib.readfile( txt_filename )
  push content
  split
  lines = pop
  question << lines.join( " " )
  if question.length > columns
    question = question[ 0...columns ]
  end
  term.puts question
end
 
exit 0
 
# End of: questions.rb
EOT
 
14. llama.cpp
-------------
 
In addition to using OpenRouter remotely to access an LLM, we are also able to
use llama.cpp locally.
 
To build and install 'llama.cpp' locally (as your regular user, not root) so
multiple versions can coexist over time, Gossip provides a script that clones
the upstream repository, checks out a pinned release tag, verifies the commit
hash, and builds a static 'llama-cli' binary under
'gossip/opt/llama.cpp-<version>/'. A symlink 'gossip/opt/llama.cpp' always
points to the active version.
 
cat > bin/make_llama.cpp.sh <<EOT
#! /bin/dash
# Do not edit this file, it gets automatically generated.
 
set -e
 
if [ "$(basename "$PWD")" != "gossip" ]; then
  echo "ERROR: we are not inside the project's 'gossip' directory."
 
  exit 2
fi
 
test -d opt || mkdir opt
 
test -d build || mkdir build
cd build
if [ -d llama.cpp ]
then
  cd llama.cpp
  git checkout master
  git fetch origin master
  git merge origin/master
else
  git clone --single-branch --branch master https://github.com/ggerganov/llama.cpp.git
  cd llama.cpp
fi
 
# Older versions are kept here for historical tracability.
#VERSION=5097
#COMMIT=fe5b78c89670b2f37ecb216306bed3e677b49d9f
#SHA256=6d19a23ea19980cb0ca73e6faaef274ba12423bf7ab83b4665369379914e3dfb
VERSION=10075
COMMIT=76f46ad29d61fd8c1401e8221842934bf62a6064
SHA256=dc20e99158cd2c5ba8bc09476ea5378cf448656fbf593f41b2ea3628678f4bf8
 
TAG=b$VERSION
 
git checkout $TAG
 
# Check the correct commit is checked out.
git log | head -n 1 | grep $COMMIT || { echo "ERROR: wrong commit."; exit 1; }
 
# Check the hash of this commit (sha1 is not enough).
git cat-file commit $COMMIT | sha256sum | grep $SHA256 \
|| { echo "ERROR: from git commit content!"; exit 1; }
 
# And build in parallel.
 
export MAKEFLAGS=-j$(nproc)
 
umask 0022
 
test -d build && rm -rf build
echo cmake
cmake --fresh -B build -DBUILD_SHARED_LIBS=OFF -DLLAMA_CURL=OFF
echo build
cmake --build build --config Release -j $(nproc)
 
#sudo chown -R root:root build
#sudo mv -iv build /opt/llama.cpp-$VERSION
mv -iv build ../../opt/llama.cpp-$VERSION
cd ../../opt
test -e llama.cpp && rm llama.cpp
ln -s llama.cpp-$VERSION llama.cpp
 
# This is software that needs regular updates, so we leave the git repository in
# place for later reuse.
 
# All installed files are in its own directory under gossip/opt.
 
echo "SUCCESS: $0 - $?."
EOT
 
15. Downloading a Minimal GGUF Model
------------------------------------
 
To test llama.cpp locally without downloading massive files, you can use a
minimal model. The Qwen 3.5 0.8B model is only about 0.8 GB and is perfect for
testing purposes and as an example of how to use llama.cpp locally.
 
You can download it directly from Hugging Face using `wget`:
 
wget -O db/gguf/Qwen3.5-0.8B-Q8_0.gguf \
https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/main/Qwen3.5-0.8B-Q8_0.gguf
 
For further reference see the Links section further down in how to identify
interesting open-weight models at Hugging Face.
 
16. Llama Text Output To Gossip Text Format
-------------------------------------------
 
The default llama-cli output has some escape codes and boilerplate text which
should be removed. We also prefer the full answer first and then followed in a
enclosed thinking section with reasong steps.
 
This output should be converted to something like this:
 
'''
[The answer...]
 
<the original think tag>
[The thinking...]
</the original think tag>
'''
 
Here is the script:
 
cat > bin/convert_llama_output.rb <<EOT
#! /usr/bin/env ruby
# Convert llama.cpp output to Gossip format
# Usage: ruby convert_llama_output.rb <input_file> [output_file]
 
require 'json'
 
def convert_llama_output(input_path, output_path = nil)
  content = File.read(input_path)
 
  # Extract thinking section (between [Start thinking] and [End thinking])
  thinking_match = content.match(/^\[Start thinking\](.*?)^\[End thinking\]/m)
  thinking = thinking_match ? thinking_match[1].strip : ""
 
  # Clean up thinking: remove trailing checkmark/emoji and whitespace
  thinking = thinking.sub(/[✅\u2705\u2714\u2713]+$/, '').strip
 
  # Extract answer - it's after [End thinking] and before the performance stats line
  # The answer starts after [End thinking] and ends before "[ Prompt:" or "Exiting..."
  after_thinking = content.split(/^\[End thinking\]/m).last || ""
 
  # Remove the performance line and "Exiting..."
  answer = after_thinking
  .sub(/^\[ Prompt:.*?\]\s*/m, '')
  .sub(/^Exiting\.\.\.\s*$/m, '')
  .strip
 
  # Build output in Gossip format
  output = "#{answer}\n\n<think>\n#{thinking}\n</think>\n"
 
  if output_path
  File.write(output_path, output)
  puts "Written to #{output_path}"
  else
  puts output
  end
end
 
if __FILE__ == $0
  if ARGV.empty? || ARGV.include?('-h') || ARGV.include?('--help')
  puts "Usage: #{$0} <input_file> [output_file]"
  puts "Converts llama.cpp CLI output to Gossip format with <think>/</think> markers."
  exit 0
  end
 
  input_file = ARGV[0]
  output_file = ARGV[1]
 
  unless File.exist?(input_file)
  warn "ERROR: Input file not found: #{input_file}"
  exit 1
  end
 
  convert_llama_output(input_file, output_file)
end
EOT
 
17. Invoking llama.cpp Locally
------------------------------
 
'bin/llama' is a thin wrapper around the local 'llama-cli' binary. It records
the prompt and answer in 'gossip/db/txt', runs the model in a single,
non-interactive turn, and reformats the raw output (e.g. from Qwen 3.6) into
cleaned up text.
 
17.1. Prerequisites
'''''''''''''''''''
 
* 'llama.cpp' has been built by 'bin/make_llama.cpp.sh'. This leaves a working
  tree at 'gossip/opt/llama.cpp' and the 'llama-cli' binary at
  'gossip/opt/llama.cpp/bin/llama-cli'.
* A model file in GGUF format is available.
 
17.2. Usage
'''''''''''
 
/path/to/gossip/bin/llama <model_file> [prompt text ...]
/path/to/gossip/bin/llama <model_file> <prompt_file>
/path/to/gossip/bin/llama <model_file> db/txt/question_YYYYMMDD_HHMMSS.txt
echo "prompt" | /path/to/gossip/bin/llama <model_file>
 
The script locates the Gossip project directory from its own path, so it may be
called from any working directory.
 
17.3. Examples
''''''''''''''
 
Ask a new question:
 
/path/to/gossip/bin/llama \
  /path/to/gossip/db/gguf/llama-3.2-3b-instruct-q4_k_m.gguf \
  "What is POSIX?"
 
Re-ask a previously recorded question with a different model:
 
/path/to/gossip/bin/llama \
  /path/to/gossip/db/gguf/Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf \
  /path/to/gossip/db/txt/question_20260705_123456.txt
 
Use any file for the prompt input:
 
/path/to/gossip/bin/llama \
  /path/to/gossip/db/gguf/Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf \
  /my/notes/big_question.txt
 
A copy of the input text will then be created in the archive location, e.g. as:
 
/path/to/gossip/db/txt/question_20260705_123456.txt
 
17.4. Behaviour
'''''''''''''''
 
* Model path. The first argument is the model file. If it cannot be found
  relative to the current directory, the script also tries to resolve it
  relative to the project directory.
* Prompt source. The prompt can be supplied in three ways:
  * As trailing arguments after the model path. They are joined with a single
    space.
  * As a single existing file name. The file is passed to 'llama-cli' with
    '-f'. This is useful for long prompts.
  * Via standard input if no prompt arguments are given.
* Reusing a recorded question. If the single file argument is a canonical
  'db/txt/question_YYYYMMDD_HHMMSS.txt' file inside the project, the script
  reuses that question file and writes the answer to the matching
  'db/txt/answer_YYYYMMDD_HHMMSS.txt'. This lets a previously stored question be
  re-asked to a different model without creating a new question file.
* Storing results. For a new prompt, a timestamp is generated with
  'bin/datetime'. The prompt is saved as 'db/txt/question_<datetime>.txt' and
  the raw model output as 'db/txt/answer_<datetime>.txt'.
  The information about the model as well as the llama-cli version that has
  been used is also conserved in the db/csv/model_<datetime>.csv file.
* Reformatting: the raw answer is converted to the Gossip format (answer first,
  then any reasoning inside the original think tags) and the '.txt' file is
  replaced by the converted text.
* Non-interactive invocation. 'llama-cli' is invoked with '--single-turn',
  '--simple-io', '--no-display-prompt', '--no-escape', '--log-disable', and
  '--temp 0.0', so it returns a single response and exits.
 
17.5. llama Script
''''''''''''''''''
 
And here is the script:
 
cat > bin/llama <<EOT
#! /bin/bash
# Do not edit this file, it gets automatically generated.
 
set -euo pipefail
 
# Determine the project directory (parent of the directory containing this script)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
#echo "SCRIPT_DIR=${SCRIPT_DIR}"
#echo "PROJECT_DIR=${PROJECT_DIR}"
 
# Use DB_DIR from caller if provided (e.g. from bin/ask),
# otherwise default to the project's own database.
if [ -z "${DB_DIR:-}" ]; then
  DB_DIR="$PROJECT_DIR/db"
fi
 
mkdir -p "$DB_DIR/csv"
mkdir -p "$DB_DIR/txt"
 
# Path to llama-cli binary
LLAMA_CLI="$PROJECT_DIR/opt/llama.cpp/bin/llama-cli"
 
if [ ! -x "$LLAMA_CLI" ]; then
  echo "ERROR: llama-cli not found or not executable at $LLAMA_CLI" >&2
  echo "Run $PROJECT_DIR/bin/make_llama.cpp.sh to build it first." >&2
  exit 1
fi
 
# Model handling: first parameter is the model file, remaining parameters form the prompt
if [ $# -lt 1 ]; then
  echo "ERROR: no model file provided as first argument." >&2
  echo "Usage: $0 <model_file> [prompt text... | prompt_file]" >&2
  exit 1
fi
 
MODEL="$1"
shift
 
# If MODEL is not found relative to CWD, try relative to PROJECT_DIR
if [ ! -f "$MODEL" ] && [ -f "$PROJECT_DIR/$MODEL" ]; then
  MODEL="$PROJECT_DIR/$MODEL"
fi
 
USE_FILE_PROMPT=0
REUSE_QUESTION=0
PROMPT_FILE=""
PROMPT=""
DATETIME=""
QUESTION_FILE=""
RESPONSE_FILE=""
ANSWER_FILE=""
 
# Canonical path to the question directory (respects DB_DIR).
QUESTION_DIR="$(readlink -f "$DB_DIR/txt")"
 
# Check if exactly one argument remains and it is an existing file
if [ $# -eq 1 ] && [ -f "$1" ]; then
  CANON_FILE="$(readlink -f "$1")"
 
  # Is this file already one of our question_<datetime>.txt files?
  if [[ "$CANON_FILE" == "$QUESTION_DIR"/question_*.txt ]]; then
    base="${CANON_FILE##*/}"
    if [[ "$base" =~ ^question_[0-9]{8}_[0-9]{6}\.txt$ ]]; then
      REUSE_QUESTION=1
      DATETIME="${base#question_}"
      DATETIME="${DATETIME%.txt}"
      QUESTION_FILE="$CANON_FILE"
      RESPONSE_FILE="$DB_DIR/txt/response_${DATETIME}.txt"
      ANSWER_FILE="$DB_DIR/txt/answer_${DATETIME}.txt"
      PROMPT_FILE="$CANON_FILE"
      USE_FILE_PROMPT=1
      echo "Reusing existing question file: $QUESTION_FILE"
    fi
  fi
 
  # Existing file, but not a known question file -> use it as prompt input
  if [ "$REUSE_QUESTION" -ne 1 ]; then
    USE_FILE_PROMPT=1
    PROMPT_FILE="$1"
  fi
elif [ $# -gt 0 ]; then
  # All remaining parameters concatenated with a space creates the total prompt
  PROMPT="$*"
else
  # If no additional parameters, read prompt from stdin
  PROMPT=$(cat)
fi
 
# If we are not reusing an existing question file, generate a new timestamp and save the prompt
if [ "$REUSE_QUESTION" -ne 1 ]; then
  if [ -z "$DATETIME" ]; then
    DATETIME=$("$PROJECT_DIR/bin/datetime")
  fi
  if [ -z "$DATETIME" ]; then
    echo "ERROR: failed to generate datetime" >&2
    exit 1
  fi
 
  QUESTION_FILE="$DB_DIR/txt/question_${DATETIME}.txt"
  RESPONSE_FILE="$DB_DIR/txt/response_${DATETIME}.txt"
  ANSWER_FILE="$DB_DIR/txt/answer_${DATETIME}.txt"
 
  if [ "$USE_FILE_PROMPT" -eq 1 ]; then
    cat "$PROMPT_FILE" > "$QUESTION_FILE"
    echo "Saved question to: $QUESTION_FILE (from file $PROMPT_FILE)"
  else
    if [ -z "$PROMPT" ]; then
      echo "ERROR: no prompt provided" >&2
      exit 2
    fi
    printf '%s\n' "$PROMPT" > "$QUESTION_FILE"
    echo "Saved question to: $QUESTION_FILE"
  fi
fi
 
# Verify the model file exists
if [ ! -f "$MODEL" ]; then
  echo "ERROR: model not found at $MODEL" >&2
  exit 2
fi
 
# Determine llama.cpp version from the opt/llama.cpp symlink target.
# The target is expected to look like llama.cpp-<VERSION>, e.g. llama.cpp-10075.
LLAMA_CPP_VERSION="unknown"
if [ -L "$PROJECT_DIR/opt/llama.cpp" ]
then
  LLAMA_CPP_TARGET=$(readlink "$PROJECT_DIR/opt/llama.cpp" || true)
  LLAMA_CPP_BASENAME=$(basename "$LLAMA_CPP_TARGET")
  LLAMA_CPP_VERSION=${LLAMA_CPP_BASENAME#llama.cpp-}
  if [ "$LLAMA_CPP_VERSION" = "$LLAMA_CPP_BASENAME" ]
  then
    LLAMA_CPP_VERSION="unknown"
  fi
fi
 
MODEL_BASENAME=$(basename "$MODEL")
 
# Invoke llama-cli and save output to answer file
# using options to surpress an interactive loop.
echo "Invoking llama-cli..."
if [ "$USE_FILE_PROMPT" -eq 1 ]; then
  "$LLAMA_CLI"        \
  -m "$MODEL"         \
  -f "$PROMPT_FILE"   \
  --temp 0.0          \
  --no-display-prompt \
  --log-disable       \
  --simple-io         \
  --single-turn       \
  --no-escape         \
  --seed 1            \
  2>&1 | tee "$RESPONSE_FILE"
else
  "$LLAMA_CLI"        \
  -m "$MODEL"         \
  -p "$PROMPT"        \
  --temp 0.0          \
  --no-display-prompt \
  --log-disable       \
  --simple-io         \
  --single-turn       \
  --no-escape         \
  --seed 1            \
  2>&1 | tee "$RESPONSE_FILE"
fi
echo "Saved answer to: $ANSWER_FILE"
 
# Write the model metadata CSV for this question/answer pair.
CSV_FILE="$DB_DIR/csv/model_${DATETIME}.csv"
printf 'llama.cpp,%s,version=%s,seed=1\n' "$MODEL_BASENAME" "$LLAMA_CPP_VERSION" > "$CSV_FILE"
echo "Saved model info to: $CSV_FILE"
 
# Optionally convert the output to Gossip format
#if command -v ruby >/dev/null 2>&1 && [ -f "$PROJECT_DIR/bin/convert_llama_output.rb" ]; then
  #CONVERTED_FILE="${ANSWER_FILE}.tmp"
  ruby "$PROJECT_DIR/bin/convert_llama_output.rb" "$RESPONSE_FILE" "$ANSWER_FILE"
  #mv "$CONVERTED_FILE" "$ANSWER_FILE"
  # The llama-cli text output and stripped markdown files are too close to be
  # worth being kept both, so we remove the initial response file.
  rm "$RESPONSE_FILE"
  echo "Converted output to gossip format: ${ANSWER_FILE}"
#fi
EOT
 
18. Local llama.cpp Server
--------------------------
 
The bin/llama script invokes llama.cpp in batch mode. That means, the large
language model has to be loaded into memory for each request. Standard is to
access the large language model via an inference server.
 
cat > ./bin/start_server.sh <<EOT
#! /bin/dash
# Do not edit this file, as it gets automatically generated by lp.
 
# Used e.g. for the Qwen 3.6 35B and 27B models.
 
# 2026-07-24
 
set -e
 
echo "START: $0 - $$."
 
LAYERS=99
 
echo "HOST=$(hostname)"
 
echo "pwd=${PWD}"
 
export MODELFILENAME="$1"
echo "MODEL=${MODELFILENAME}"
BASEMODEL=${MODELFILENAME##*/}
echo "${BASEMODEL}" > db/csv/llama-server_model.csv
 
THREADS=$(nproc)
echo "THREADS=${THREADS}"
 
LOGFILENAME=log/gossip.log
echo "logfile=${LOGFILENAME}"
 
server="opt/llama.cpp/bin/llama-server"
echo \
$server -v --temp 0.0 -s 1 -t $THREADS -ngl $LAYERS -cram 0 -m $MODELFILENAME
$server -v --temp 0.0 -s 1 -t $THREADS -ngl $LAYERS -cram 0 -m $MODELFILENAME > $LOGFILENAME 2>&1
 
# End of: start_server.sh
EOT
 
This script must be started from the Gossip project directory.
 
19. The llama.cpp Client
------------------------
 
We assume the llama.cpp server has been started as it waiting at
http://localhost:8080/ for requests. The script to send requests and receive
responses is what bin/ask_curl.rb is about.
 
cat > ./bin/ask_curl.rb <<EOT
#!/usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
# This script encapsulates the JSON payload creation, 'curl' invocation,
# response parsing, and answer file writing.
 
$: << File.dirname(__FILE__) + '/../lib'
 
require 'rlib'
require 'json'
 
# Usage: ask_curl.rb <prompt_file> <seed> <temp> <repeat_penalty> <output_file>
prompt_file = ARGV[0]
seed = ARGV[1]
temp = ARGV[2]
repeat_penalty = ARGV[3]
output_file = ARGV[4]
json_request_file = prompt_file.sub( /\/txt\//, '/json/' ).sub( /\/question_/, '/request_' ).sub( /\.txt$/, '.json' )
json_response_file = json_request_file.sub( /\/request_/, '/response_' )
 
# Read the prompt (question + formatting)
prompt = Rlib.readfile_assert(prompt_file).chomp
 
# Prepare JSON payload
data_json = {
  "prompt" => prompt,
  "seed" => seed.to_i,
  "temperature" => temp.to_f,
  "repeat_penalty" => repeat_penalty.to_f
}.to_json
Rlib.writefile_assert(json_request_file, data_json)
 
# Execute curl
command = "curl --silent --request POST --url 'http://127.0.0.1:8080/completion' --header 'Content-Type: application/json' -d @#{json_request_file}"
puts command
json_output = Rlib.output_assert(command)
#json_filename = prompt_file.gsub( /csv/, "json" )
#json_filename.sub!( /question_/, 'reply_' )
Rlib.writefile_assert( json_response_file, json_output )
 
# Parse and display response
h = JSON.parse(json_output)
puts "====="
h.keys.each do |key|
  next if key == "content"
  puts
  puts "key=#{key}"
  puts h[key]
  puts "==="
end
puts
puts "====="
answer = h["content"]
puts answer
puts "====="
 
# Save the answer
Rlib.writefile_assert(output_file, answer)
puts "File written: #{output_file}"
 
puts "SUCCESS: #{__FILE__} - 0."
 
# End of: ask_curl.rb
EOT
 
20. llama_call
--------------
 
bin/llama_call submits prompts to a running local llama.cpp server (started
separately via 'bin/start_server.sh') and archives the resulting
question/answer pair in the Gossip database, mirroring the interface and
storage conventions of 'bin/llama'.
 
The model basename is read from 'db/csv/llama-server_model.csv', which
'bin/start_server.sh' writes when the server is started.
 
Prompts are accepted the same way as 'bin/llama': trailing arguments joined by
a space, a single file argument, a canonical 'question_<datetime>.txt' file for
reuse, piped stdin, or an interactive editor when no arguments are given.
 
cat > ./bin/llama_call <<EOT
#! /bin/bash
# Do not edit this file, as it gets automatically generated.
 
set -euo pipefail
 
# Determine the project directory (parent of the directory containing this script)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
 
print_usage() {
  echo "Usage: $0 [prompt text...]"
  echo "       $0 <prompt_file>"
  echo "       $0 db/txt/question_YYYYMMDD_HHMMSS.txt"
  echo "       echo \"prompt\" | $0"
  echo "       $0                          # Opens interactive editor"
  echo ""
  echo "Submits a prompt to a running local llama.cpp server and archives the"
  echo "question/answer pair in the Gossip database."
  echo ""
  echo "Modes:"
  echo "  - Arguments: joined with spaces as the prompt"
  echo "  - File arg:  prompt read from file"
  echo "  - Question file: reuses existing Q/A pair (matches timestamp)"
  echo "  - No args:   opens \$EDITOR interactively"
  echo "  - Piped stdin: reads prompt from pipe"
  echo ""
  echo "The model is determined from db/csv/llama-server_model.csv, which is"
  echo "written by bin/start_server.sh when the server is started."
  echo ""
  echo "Like bin/ask this script supports question/answer databases inside"
  echo "other projects: if the current directory contains a db/txt directory,"
  echo "that project's database is used. If a reused question file lives in"
  echo "another project's db/txt, the answer and response files are written"
  echo "to the analog locations next to it."
}
 
# Safely check for help flag (avoids unbound variable with set -u)
if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
  print_usage
  exit 0
fi
 
# Use DB_DIR from caller if provided (e.g. from bin/ask),
# otherwise default to the project's own database.
if [ -z "${DB_DIR:-}" ]; then
  DB_DIR="$PROJECT_DIR/db"
fi
 
mkdir -p "$DB_DIR/csv"
mkdir -p "$DB_DIR/txt"
mkdir -p "$DB_DIR/json"
 
# Verify that the llama-server is reachable.
#if ! curl --silent --fail http://127.0.0.1:8080/health > /dev/null 2>&1; then
#  if ! curl --silent --fail http://127.0.0.1:8080/ > /dev/null 2>&1; then
#  echo "ERROR: llama-server does not appear to be running at http://127.0.0.1:8080/" >&2
#  echo "Start it first with: $PROJECT_DIR/bin/start_server.sh <model.gguf>" >&2
#  exit 1
#  fi
#fi
 
# Server model is always specified by the Gossip configuration.
SERVER_MODEL_FILE="${PROJECT_DIR}/db/csv/llama-server_model.csv"
MODEL_BASENAME=""
if [ -f "$SERVER_MODEL_FILE" ]; then
  MODEL_BASENAME="$(tr -d '\n' < "$SERVER_MODEL_FILE")"
  echo "Using model from $SERVER_MODEL_FILE: $MODEL_BASENAME"
else
  echo "ERROR: no model basename provided and $SERVER_MODEL_FILE not found." >&2
  exit 1
fi
 
# Initialize flags
USE_FILE_PROMPT=0
REUSE_QUESTION=0
PROMPT_FILE=""
PROMPT=""
DATETIME=""
QUESTION_FILE=""
ANSWER_FILE=""
JSON_FILE=""
 
# Canonical path to the question directory (respects DB_DIR).
QUESTION_DIR="$(readlink -f "$DB_DIR/txt")"
 
# 1. Check if a single argument is a known question file
if [ $# -eq 1 ] && [ -f "$1" ]; then
  CANON_FILE="$(readlink -f "$1")"
  if [[ "$CANON_FILE" == "$QUESTION_DIR"/question_*.txt ]]; then
    base="${CANON_FILE##*/}"
    if [[ "$base" =~ ^question_[0-9]{8}_[0-9]{6}\.txt$ ]]; then
      REUSE_QUESTION=1
      DATETIME="${base#question_}"
      DATETIME="${DATETIME%.txt}"
      QUESTION_FILE="$CANON_FILE"
      ANSWER_FILE="$DB_DIR/txt/answer_${DATETIME}.txt"
      JSON_FILE="$DB_DIR/json/response_${DATETIME}.json"
      PROMPT_FILE="$CANON_FILE"
      USE_FILE_PROMPT=1
      echo "Reusing existing question file: $QUESTION_FILE"
    fi
  fi
fi
 
# 2. Handle piped/redirected stdin (non-terminal)
if [ "$REUSE_QUESTION" -ne 1 ] && [ ! -t 0 ]; then
  PROMPT=$(cat)
  if [ -z "$PROMPT" ]; then
    echo "ERROR: empty prompt from stdin" >&2
    exit 2
  fi
  # Falls through to new prompt handling below
# 3. Handle no arguments -> interactive editor
elif [ "$REUSE_QUESTION" -ne 1 ] && [ $# -eq 0 ]; then
  DATETIME=$("$PROJECT_DIR/bin/datetime")
  QUESTION_FILE="$DB_DIR/txt/question_${DATETIME}.txt"
  ANSWER_FILE="$DB_DIR/txt/answer_${DATETIME}.txt"
  JSON_FILE="$DB_DIR/json/response_${DATETIME}.json"
  touch "$QUESTION_FILE"
  echo "Created question file: $QUESTION_FILE"
  ${EDITOR:-vi} "$QUESTION_FILE"
  USE_FILE_PROMPT=1
  PROMPT_FILE="$QUESTION_FILE"
  REUSE_QUESTION=1
# 4. Handle regular file argument (not a question file)
elif [ "$REUSE_QUESTION" -ne 1 ] && [ $# -eq 1 ] && [ -f "$1" ]; then
  USE_FILE_PROMPT=1
  PROMPT_FILE="$1"
# 5. Handle command-line arguments
elif [ "$REUSE_QUESTION" -ne 1 ] && [ $# -gt 0 ]; then
  PROMPT="$*"
fi
 
# Generate new prompt/question if not reusing one
if [ "$REUSE_QUESTION" -ne 1 ]; then
  if [ -z "$DATETIME" ]; then
    DATETIME=$("$PROJECT_DIR/bin/datetime")
  fi
  if [ -z "$DATETIME" ]; then
    echo "ERROR: failed to generate datetime" >&2
    exit 1
  fi
 
  QUESTION_FILE="$DB_DIR/txt/question_${DATETIME}.txt"
  ANSWER_FILE="$DB_DIR/txt/answer_${DATETIME}.txt"
  JSON_FILE="$DB_DIR/json/response_${DATETIME}.json"
 
  if [ "$USE_FILE_PROMPT" -eq 1 ]; then
    cat "$PROMPT_FILE" > "$QUESTION_FILE"
    echo "Saved question to: $QUESTION_FILE (from file $PROMPT_FILE)"
  else
    if [ -z "$PROMPT" ]; then
      echo "ERROR: no prompt provided" >&2
      exit 2
    fi
    printf '%s\n' "$PROMPT" > "$QUESTION_FILE"
    echo "Saved question to: $QUESTION_FILE"
  fi
fi
 
# Determine llama.cpp version from the opt/llama.cpp symlink target.
LLAMA_CPP_VERSION="unknown"
if [ -L "$PROJECT_DIR/opt/llama.cpp" ]; then
  LLAMA_CPP_TARGET=$(readlink "$PROJECT_DIR/opt/llama.cpp" || true)
  LLAMA_CPP_BASENAME=$(basename "$LLAMA_CPP_TARGET")
  LLAMA_CPP_VERSION=${LLAMA_CPP_BASENAME#llama.cpp-}
  if [ "$LLAMA_CPP_VERSION" = "$LLAMA_CPP_BASENAME" ]; then
    LLAMA_CPP_VERSION="unknown"
  fi
fi
 
# Invoke ask_curl.rb to talk to the server.
echo "Invoking llama-server via ask_curl.rb..."
ruby "$PROJECT_DIR/bin/ask_curl.rb" \
  "$QUESTION_FILE" \
  1 \
  0.0 \
  1.0 \
  "$ANSWER_FILE"
 
# Write the model metadata CSV for this question/answer pair.
CSV_FILE="$DB_DIR/csv/model_${DATETIME}.csv"
printf 'llama.cpp,%s,version=%s,seed=1\n' "$MODEL_BASENAME" "$LLAMA_CPP_VERSION" > "$CSV_FILE"
echo "Saved model info to: $CSV_FILE"
 
echo "Saved answer to: $ANSWER_FILE"
echo "Saved response JSON to: $JSON_FILE"
echo "SUCCESS: $0 - $?."
 
# End of: llama_call
EOT
 
21. Understanding Pi Dev
------------------------
 
Pi takes the directory the the user is when he or she invokes the pi command to
be the project directory. As the user activity get logged per session,
different sessions for different projects accumulate under
~/.pi/agent/sessions. In that directory the initial absolute user location is
encrypted by replacing in the absolute path '/' with '-' and adding '-' to the
front.
 
The pi.dev application directory is, in my case, located at:
 
cd .local/share/pi-node/node-*/lib/node_modules/@earendil-works/pi-coding-agent
 
22. Multi Turn pi.dev Interactions
----------------------------------
 
Example session lp investigation 2026-07-20:
 
projects (locations in ~/.pi/agent/sessions/)
  sessions
    session / jsonl
      events
        event
          type
            session
            thinking_level_change
            model_change
            message
              role
                user
                assistant
                  text
                  thinking
                  toolCall
                  toolResult
 
messages
  user
  assistant
  toolcall
  toolcall
  toolresult
  toolresult
  assistant
  toolcall
  toolcall
  toolcall
  toolcall
  toolresult
  toolresult
  toolresult
  toolresult
  assistant
  toolcall
  toolcall
  toolcall
  toolresult
  toolresult
  toolresult
  assistant
  toolcall
  toolcall
  toolresult
  toolresult
  assistant
  answer
  toolcall
  toolresult
  toolcall
  toolresult
  assistant
  answer
  toolcall
  toolcall
  toolresult
  toolresult
  assistant
  answer
 
23. Interaction With Pi Dev
---------------------------
 
Let's read for what has happened in the pi.dev environment so far.
 
The object hierarchy structure looks like:
 
session (directory)
  jsonl (file)
    event (hash)
      session (attributes: timestamp, dir)
      message
 
The data structure is jsonl files, one for each session.
In each file there are messages, each message has a type.
 
cat > bin/pi_sessions.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'json'
require 'rlib'
require 'stack'
require 'term'
 
def read_jsonl( jsonl_filename )
  ret_val = []
  File.foreach( jsonl_filename ) do |line|
  line = line.strip
  next if line.empty?  # skip blank lines
 
  begin
    record = JSON.parse(line)
    ret_val << record
  rescue JSON::ParserError => e
    warn "Bad line: #{e.message}"
    exit 2
  end
  end
 
  return ret_val
end
 
require 'time'
 
def get_datetime( ts_ms )
  utc_time = Time.at(ts_ms / 1000r).utc
  ret_val = utc_time.iso8601(3)
  ret_val.gsub!( /[\-:]/, '' )
  ret_val.sub!( /T/, '_' )
  ret_val.sub!( /\..*$/, '' )
  return ret_val
end
 
dir = "#{ENV[ 'HOME' ]}/.pi/agent/sessions/"
 
if !File.exist?( dir )
  puts "Directory not found: #{dir}"
  exit 1
end
 
script_dir = File.dirname( File.expand_path( __FILE__ ) )
project_dir = File.dirname( script_dir )
 
command = "find #{dir} -type f -name '*.jsonl' | sort -n"
output = Rlib.output( command  )
jsonl_files = output.split( /\n/ )
event_types = {}
thinking_level = nil
router = nil
model = nil
new_question_answer = false
answer = ""
think = ""
jsonl_files.each do |jsonl_filename|
  puts "jsonl_filename=#{jsonl_filename}"
  events = read_jsonl( jsonl_filename )
  puts "events=#{events.length}"
  datetime = ""
  events.each do |event|
    puts "event_type=#{event[ "type" ]}"
    # Event fields.
    #puts event.inspect
    #exit 3
    event.keys.each do |key|
      printf "%s, ", key
    end
    puts
    if event[ "type" ] == "message"
      message = event[ "message" ]
      timestamp = message[ "timestamp" ]
      #temp_datetime( timestamp )
      puts "message=#{message.inspect}"
      content = message[ "content" ]
      if message[ "role" ] == "user"
        Rlib.assert( content.length == 1, \
                     "ERROR: content assumption is wrong: " \
                     + "#{content.length}" )
        content = content[ 0 ]
        #puts content.inspect
        timestamp = message[ "timestamp" ]
        #puts timestamp
        datetime = get_datetime( timestamp )
        #puts datetime
        txt_filename = "#{project_dir}/db/txt/question_#{datetime}.txt"
        #exit 3
        puts "\nQuestion #{datetime}:\n\n"
        question = content[ content[ "type" ] ]
        puts question
        puts
        Rlib.assert( router != nil )
        Rlib.assert( model != nil )
        model_content = "pi.dev,#{model}" \
                        + ",reasoning=#{thinking_level}\n"
        model_filename = txt_filename.sub( /question/, "model" ).sub( /\/txt\//,  '/csv/' ).sub( /\.txt$/, '.csv' )
        if File.exist?( txt_filename ) == false
          Rlib.writefile( txt_filename, question )
          puts "Wrote: #{txt_filename}"
          new_question_answer = true
        else
          new_question_answer = false
        end
        if File.exist?( model_filename ) == false
          Rlib.writefile( model_filename, model_content )
          puts "Wrote: #{model_filename}"
        end
        answer = ""
        think  = ""
      elsif message[ "role" ] == "assistant"
        puts "assistant.content=#{content.inspect}"
        content.each do |next_content|
          if next_content[ "type" ] == "text"
            local_answer = next_content[ "text" ]
            # If not likely will just invoke another tool and there is nothing to say.
            if answer != " "
              #puts "content="
              #puts next_content.inspect
              puts "\nAnswer #{datetime}:\n\n"
              if answer > ""
                answer << "\n\n"
              end
              answer << local_answer
              puts answer
              puts
              txt_filename = "#{project_dir}/db/txt/answer_#{datetime}.txt"
              if new_question_answer
                answer_think = String.new( answer )
                if think > ""
                  answer_think << "\n\n<think>\n#{think}\n</think>\n"
                end
                Rlib.writefile( txt_filename, answer_think )
                puts "Wrote: #{txt_filename}"
              end
            end
          elsif next_content[ "type" ] == "toolCall"
            #puts next_content.inspect
            puts "\nToolcall:\n\n"
            local_tool = next_content[ "name" ]
            if local_tool == "read"
              print "less '"
              print next_content[ "arguments" ][ "path" ]
              puts "'"
              think << "\n\ncat #{next_content[ 'arguments' ][ 'path' ]}"
            else
              print local_tool
              print " "
              puts next_content[ "arguments" ][ "command" ]
              think << "\n\n#{local_tool} #{next_content[ 'arguments' ][ 'command' ]}"
            end
            puts
          elsif next_content[ "type" ] == "thinking"
            puts "assistant.thinking"
            puts next_content.inspect
            local_think = next_content[ "thinking" ]
            if think > ""
              think << "\n\n"
            end
            think << local_think
            puts "<think>\n#{think}\n</think>\n"
          else
            # Everything else is a surprise.
            Rlib.assert( false )
          end
        end
      elsif message[ "role" ] == "toolResult"
        # They can be quite long and we skip them for now.
        puts "Skipping tool result."
      else
        #puts content.inspect
        puts message[ "role" ].inspect
        puts "what?"
        exit 3
        puts content[ 0 ][ "type" ].inspect
      puts content[ content[ "type" ] ]
      end
    elsif event[ "type" ] == "session"
      dir = event[ "cwd" ]
    elsif event[ "type" ] == "thinking_level_change"
      thinking_level = event[ "thinkingLevel" ]
    elsif event[ "type" ] == "model_change"
      router = event[ "provider" ]
      model = event[ "modelId" ]
    else
      puts "ERROR: we got something else than a session or a thinking level" \
           " change or message: #{event.inspect}"
 
      exit 2
    end
    event_types[ event[ "type" ] ] = \
      event_types[ event[ "type" ] ].to_i + 1
  end
  puts "#------------------------------"
end
puts event_types.inspect
 
exit 0
EOT
 
24. pi.dev Models Storage
-------------------------
 
Location:
 
pi stores all available models in two JSON files at ~/.pi/agent/ (or at
$PI_CODING_AGENT_DIR/agent/ if that environment variable is set).
 
Files:
 
  1. models.json
     - Missing by default - the path is: ~/.pi/agent/models.json
     - It contains user configured provider settings and model
       definitions/overrides.
 
  2. models-store.json
     - Cached overlay of dynamically refreshed model catalogs fetched from
       pi.dev.
     - Format: JSON, keyed by provider ID, each entry containing a "models"
       array of model objects with id, name, provider, cost, contextWindow, etc.
     - Default path: ~/.pi/agent/models-store.json
     - This file is populated by polling pi.dev at
       https://pi.dev/api/models/providers/{providerId} every 4 hours
       (REMOTE_CATALOG_REFRESH_INTERVAL_MS).
 
  3. Built-in model source (shipped with the @earendil-works/pi-ai npm package):
     - Per-provider JSON files in the providers/data/ directory (e.g.,
       amazon-bedrock.json, anthropic.json, openai.json, etc.).
     - A .manifest.json file listing all provider JSON files with content
       hashes and a generation timestamp (schemaVersion: 3).
     - These are bundled into models.generated.js at build time.
 
Format:
 
All model data is stored as JSON.
 
25. pi.dev OpenRouter Models
----------------------------
 
What follows is the Ruby script 'bin/pi_models_openrouter.rb' prints out all
available OpenRouter LLM models.
 
1. Reads '~/.pi/agent/models-store.json'
2. Extracts the "openrouter" key which contains an array of models
3. Filters models where the "input" array contains "text"
4. Sorts the remaining models alphabetically by "id"
5. Prints the numbered models with all info: id, input, cost (input, output,
   cacheRead, cacheWrite), contextWindow, maxTokens
 
Here is an example of The JSON structure for models-store.json:
 
'''
{
  "openrouter": [
    {
      "id": "model-id",
      "input": ["text", "image"],
      "cost": {
        "input": 0.01,
        "output": 0.02,
        "cacheRead": 0.001,
        "cacheWrite": 0.002
      },
      "contextWindow": 128000,
      "maxTokens": 4096
    }
  ]
}
'''
 
cat > bin/pi_models_openrouter.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
feature_free_only = false
if [ "free", "--free" ].include?( ARGV[ 0 ] )
  feature_free_only = true
end
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'json'
require 'rlib'
require 'term'
 
models_store_filename = "#{ENV[ 'HOME' ]}/.pi/agent/models-store.json"
 
if !File.exist?( models_store_filename )
  puts "File not found: #{models_store_filename}"
  exit 1
end
 
content = Rlib.readfile( models_store_filename )
models_store = JSON.parse( content )
 
openrouter = models_store[ "openrouter" ]
if openrouter.nil?
  puts "No 'openrouter' key found in #{models_store_filename}"
 
  exit 2
end
 
models = openrouter[ "models" ]
#puts "models=#{models.length}"
 
# Filter models that support text input
text_models = models.each do |model|
  model[ "input" ].include?( "text" )
end
#puts "text_models=#{text_models.length}"
 
# Sort alphabetically by id
text_models.sort_by! { |model| model[ "id" ] }
 
count = 0
text_models.each_with_index do |model, index|
  cost = model[ 'cost' ]
  cost_average = (cost[ 'input' ].to_f + cost[ 'output' ].to_f) / 2.0
  if (cost_average != 0.0) && feature_free_only
    next
  end
  count += 1
  number = count
  puts "#{number}. #{model[ 'id' ]}"
  #puts "  input: #{model[ 'input' ].join( ', ' )}"
  #puts "  cost:"
  #puts "    input: #{cost[ 'input' ]}"
  #puts "    output: #{cost[ 'output' ]}"
  cost_average = ((cost[ 'input' ].to_f + cost[ 'output' ].to_f) / 2.0).to_s.sub( /\.0$/, '' )
  puts "  cost   =#{cost[ 'input' ]}/#{cost[ 'output' ]}/#{cost_average}"
  #puts "    cacheRead: #{cost[ 'cacheRead' ]}"
  #puts "    cacheWrite: #{cost[ 'cacheWrite' ]}"
  puts "  context=#{model[ 'contextWindow' ]}"
  puts "  output =#{model[ 'maxTokens' ]}"
  puts
end
 
exit 0
 
# End of: pi_models_openrouter.rb
EOT
 
26. Configured OpenRouter Models
--------------------------------
 
Here is a Ruby script that prints beautified all in
db/csv/openrouter_models.csv configured models:
 
cat > bin/openrouter_models.rb <<EOT
#!/usr/bin/env ruby
# coding: utf-8
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'rlib'
require 'term'
 
# Resolve the project root directory from this script's location,
# so the script works regardless of the current working directory.
script_dir = File.dirname( File.expand_path( __FILE__ ) )
project_dir = File.dirname( script_dir )
 
term = Term.new
columns = term.cols
 
csv_filename = File.join( project_dir, "db/csv/openrouter_models.csv" )
 
unless File.exist?( csv_filename )
  puts "File not found: #{csv_filename}"
  exit 1
end
 
models = []
File.foreach( csv_filename ) do |line|
  line = line.strip
  next if line.empty?
  next if line.start_with?( '#' )
 
  parts = line.split( ',' )
  next if parts.length < 2
 
  model_id = parts[ 0 ].strip
  provider = parts[ 1 ].strip
  next if model_id.empty?
 
  # Split model_id into lab and model name at the first '/'.
  if model_id.include?( '/' )
  lab, model_name = model_id.split( '/', 2 )
  else
  lab = ""
  model_name = model_id
  end
 
  models << [ lab, model_name, provider ]
end
 
if models.empty?
  puts "No models configured in #{csv_filename}"
  exit 0
end
 
# Column widths
max_lab_len = [ models.map { |m| m[ 0 ].length }.max, 3 ].max
max_model_len = [ models.map { |m| m[ 1 ].length }.max, 4 ].max
max_provider_len = [ models.map { |m| m[ 2 ].length }.max, 8 ].max
 
# Print header
puts "    Lab".ljust( max_lab_len ) + " " +
   "    Model".ljust( max_model_len ) + " " +
   "    Provider"
puts "    " + "-" * max_lab_len + " " +
   "-" * max_model_len + " " +
   "-" * max_provider_len
 
# Print rows
models.each_with_index do |( lab, model_name, provider ), index|
  number = sprintf( "%#{models.length.to_s.length}d", index + 1 )
  line = "#{number}. #{lab.ljust( max_lab_len )} #{model_name.ljust( max_model_len )} #{provider}"
  line = line[ 0...columns ] if line.length > columns
  term.puts line
end
 
#puts
#puts "#{models.length} model(s) configured."
 
exit 0
 
# End of: openrouter_models.rb
EOT
 
How it works:
 
1. Reads 'db/csv/openrouter_models.csv' line by line.
2. Skips empty lines and comment lines (starting with '#').
3. Splits each line on the first comma to extract 'model_id' and 'provider'.
4. Splits 'model_id' at the first '/' into 'lab' (the upstream provider,
   e.g. 'deepseek') and 'model_name' (the specific model,
   e.g. 'deepseek-v4-pro').
5. Calculates column widths based on the longest entries for aligned output.
6. Prints a numbered, aligned table with a header and separator line showing
   Lab, Model, and Provider columns.
7. Truncates lines that exceed the terminal width (using 'term.cols').
 
27. Main Gossip Script
----------------------
 
The gossip.rb script is the main entrance for the user to access different
functionality from the command line.
 
cat > bin/gossip.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'json'
require 'rlib'
require 'stack'
require 'term'
 
if ARGV.include?( "help" ) || ARGV.include?( "--help" )
  puts "Usage: gossip [COMMAND]"
  puts
  puts "Gossip is an LLM frontend for the command line."
  puts
  puts "Commands:"
  puts "  ask <modelid> [prompt...]  Ask an OpenRouter model a question."
  puts "  questions                  List local questions stored under db/txt."
  print "  question [datetime]        Create a new empty question file under db/txt"
  puts " and print its name."
  puts "  models [free|configured]   List OpenRouter models."
  puts "  pisessions                 Read and process pi.dev sessions from: " +
       "~/.pi/agent/sessions/"
  puts "  help, --help               Display this help message and exit."
  puts "  version, --version         Print out the application name and version."
 
  exit 0
elsif ARGV.include?( "version" ) || ARGV.include?( "--version" )
  command = "git log | grep -E '^commit ' | wc -l"
  output = Rlib.output( command )
  if output
    puts "gossip #{output}"
  else
    puts "ERROR: git is not available."
 
    exit 1
  end
 
  exit 0
end
 
#feature = "pisessions"
feature = "questions"
if ARGV[ 0 ] == "pisessions"
  feature = "pisessions"
elsif ARGV[ 0 ] == "question"
  feature = "question"
elsif ARGV[ 0 ] == "ask"
  feature = "ask"
elsif ARGV[ 0 ] == "models"
  feature = "models"
end
 
BIN_DIR = File.dirname( File.expand_path( __FILE__ ) )
 
if feature == "questions"
  system "#{BIN_DIR}/questions.rb"
elsif feature == "question"
  system "#{BIN_DIR}/question #{ARGV[ 1..-1 ].join( ' ' )}"
elsif feature == "ask"
  #command = "#{BIN_DIR}/ask #{ARGV[ 1..-1 ].join( ' ' )}"
  #puts command.inspect
  #exit 3
  system "#{BIN_DIR}/ask #{ARGV[ 1..-1 ].join( ' ' )}"
elsif feature == "pisessions"
  dir = "#{ENV[ 'HOME' ]}/.pi/agent/sessions/"
 
  if File.exist?( dir )
    command = "ls -1 #{dir}"
    output = Rlib.output( command )
    puts output
  end
 
  system "#{BIN_DIR}/pi_sessions.rb"
elsif feature == "models"
  if ARGV[ 1 ] == "configured"
    system "#{BIN_DIR}/openrouter_models.rb"
  else
    system "#{BIN_DIR}/pi_models_openrouter.rb #{ARGV[ 1..-1 ].join( ' ' )}"
  end
end
 
exit 0
# End of: gossip.rb
EOT
 
28. Print Answer
----------------
 
In the following script date and time is used for input as in the list of
questions for easy copying and pasting:
 
Alternatively the date time timestamp to select the answer file and to print it
out.
 
cat > ./bin/print_answer.sh <<EOT
#! /bin/dash
# Do not edit this file, as it gets automatically generated by lp.
 
DATE=$1
TIME=$2
 
# This script prints out the text anser according to the date and time given as
# separate parameters.
 
# Alternative usages:
# ./bin/print_answer.sh 23-10-25 14:30
# ./bin/print_answer.sh 20231025_143022
 
if [ $# -eq 1 ]; then
  DATETIME="${1}"
elif [ $# -eq 2 ]; then
  DATETIME="20$(echo "${DATE}_${TIME}" | perl -pe 's/[-:]//g;')??"
else
  echo "Usage: $0 <YYYYMMDD_HHMMSS> | <YY-MM-DD> <HH:MM>"
  exit 2
fi
TXT_FILE=db/txt/answer_${DATETIME}.txt
#MD_FILE=db/txt/answer_${DATETIME}.txt
 
if [ -f $TXT_FILE ]; then
  cat $TXT_FILE
  echo
  echo $TXT_FILE
#elif [ -f $MD_FILE ]; then
#  cat $MD_FILE
#  echo
#  echo $MD_FILE
else
  echo "ERROR: no answer file found for ${DATETIME}" >&2
  exit 1
fi
 
# End of: print_answer.sh
EOT
 
cat > bin/print_answer.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'rlib'
 
if ARGV.length != 1
  puts "Usage: #{$0} <number>"
  puts "  Positive n: n-th oldest answer (1 = first)."
  puts "  Negative n: n-th newest answer (-1 = last)."
  exit 1
end
 
index = ARGV[0].to_i
 
dir = "db/txt"
txt_files = Dir.glob( "#{dir}/answer_*.txt" )
#md_files  = Dir.glob( "#{dir}/answer_*.md" )
#all_files = (txt_files + md_files).sort
all_files = txt_files
all_files.sort!
 
n = all_files.length
 
if n == 0
  puts "Error: no answer files found in #{dir}/"
  exit 1
end
 
resolved_index = if index > 0
  index - 1
elsif index < 0
  n + index  # Ruby-style negative indexing
else
  -1  # index == 0 is invalid; will be caught below
end
 
if resolved_index < 0 || resolved_index >= n
  puts "Error: only #{n} answer(s) available, index #{index} is out of range"
  exit 1
end
 
filename = all_files[resolved_index]
content = Rlib.readfile( filename )
if content.nil?
  puts "Error: cannot read #{filename}"
  exit 1
end
print content
puts
puts filename
 
exit 0
 
# End of: print_answer.rb
EOT
 
29. Print Question
------------------
 
The questions.rb file lists all available questions in chronological order.
 
The script bin/print_question.rb
a) Takes a number as an argument (1-based index),
b) prints the corresponding question from the database, while the
c) oldest question is number 1 and the newest is number n.
d) At the end the question file name is printed so the user can quickly copy
   and paste the name for further processing.
 
cat > bin/print_question.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'rlib'
 
if ARGV.length != 1
  puts "Usage: #{$0} <number>"
  puts "  Oldest question is 1, newest is n."
  exit 1
end
 
index = ARGV[0].to_i
if index < 1
  puts "Error: index must be >= 1"
  exit 1
end
 
dir = "db/txt"
txt_files = Dir.glob( "#{dir}/question_*.txt" )
txt_files.sort!
 
if index > txt_files.length
  puts "Error: only #{txt_files.length} question(s) available"
  exit 1
end
 
txt_filename = txt_files[ index - 1 ]
content = Rlib.readfile( txt_filename )
print content
puts txt_filename
 
exit 0
 
# End of: print_question.rb
EOT
 
30. Tags
--------
 
The Tag database file is 'db/csv/tags.csv' and here is an example content to
demonstrate its standard CSV file format. The number of tags for a single
question model answer triplet is open ended, just add more tags to the same
line each separated by a comma:
 
'''
20250512_174554,gossip
20250614_223442,c
20260709_073007,emacs,git
'''
 
We need a tool to get an overview of all tags and to drill down a little bit to
be able to find a certain question for a given tag.
 
Here's a Ruby script to read the tags CSV file, which builds an internal hash
of all tags and prints out some overview with most common tags on top. If an
optional tag name has been provided, the list of all questions tagged
accordingly is printed out in reverse chronological order.
 
The script:
1. Reads 'db/csv/tags.csv' line by line
2. Splits each line by comma - first field is the datetime, rest are tags
3. Builds a hash where each tag maps to an array of datetimes
4. Sorts each datetime array chronologically
5. Outputs the result (you can also 'require' this and use 'read_tags_csv'
   directly in other scripts)
6. Added argument handling at the beginning of the main execution block
7. When a tag argument is provided ('ARGV[0]'):
   - Looks up the tag in the tags hash
   - If found, prints each question in a single line (newest first)
   - If not found, shows an error message and exits with status 1
 
Usage Examples:
 
# List all tags with counts (original behavior)
$ bin/tags.rb
1. gossip                    42
2. emacs                     35
3. git                       28
 
The program:
 
cat > bin/tags.rb <<EOT
#! /usr/bin/env ruby
# Do not edit this file, as it gets automatically generated by lp.
 
$: << File.dirname( __FILE__ ) + '/../lib'
 
require 'rlib'
require 'term'
 
def read_tags_csv( csv_filename )
  tags_hash = {}
 
  return tags_hash unless File.exist?( csv_filename )
 
  File.foreach( csv_filename ) do |line|
  line = line.strip
  next if line.empty?
 
  fields = line.split( ',' )
  next if fields.length < 2
 
  datetime = fields[ 0 ]
  tags = fields[ 1..-1 ]
 
  tags.each do |tag|
  tag = tag.strip
  next if tag.empty?
 
  tags_hash[ tag ] ||= []
  tags_hash[ tag ] << datetime
  end
  end
 
  # Sort the datetime arrays for each tag (newest first)
  tags_hash.each_value do |datetimes|
  datetimes.sort!
  datetimes.reverse!
  end
 
  return tags_hash
end
 
# Main execution
csv_file = "db/csv/tags.csv"
tags = read_tags_csv( csv_file )
 
# Check if a tag argument was provided
if ARGV[0]
  tag = ARGV[0]
  if tags.key?(tag)
  datetimes = tags[tag]
  # Calculate width for right-aligned numbering
  num_width = datetimes.length.to_s.length
 
  # Get terminal width for text truncation
  term = Term.new
  columns = term.cols
 
  datetimes.each_with_index do |dt, index|
    num = (index + 1).to_s.rjust(num_width)
    # Convert YYYYMMDD_HHMMSS to YY-MM-DD HH:MM
    year_short = dt[2,2]   # YY from YYYY
    month = dt[4,2]        # MM
    day = dt[6,2]          # DD
    hour = dt[9,2]         # HH
    minute = dt[11,2]      # MM
    short_dt = "#{year_short}-#{month}-#{day} #{hour}:#{minute}"
 
    # Read and process question content
    txt_filename = "db/txt/question_#{dt}.txt"
    if File.exist?(txt_filename)
      content = Rlib.readfile(txt_filename)
      # Split content into lines and join with spaces (mimicking questions.rb behavior)
      lines = content.split(/\n/)
      question_content = lines.join(" ")
    else
      question_content = "[Question file not found]"
    end
 
    # Build full line and truncate to terminal width
    full_line = "#{num}. #{short_dt} #{question_content}"
    if full_line.length > columns
      full_line = full_line[0...columns]
    end
    puts full_line
  end
  else
  warn "ERROR: Tag '#{tag}' not found in #{csv_file}"
  exit 1
  end
else
  # Original behavior: list all tags with counts
  # Build array of [tag, count] and sort by count desc, then tag asc
  tag_counts = tags.map { |tag, datetimes| [tag, datetimes.length] }
  tag_counts.sort_by! { |tag, count| [-count, tag] }
 
  # Determine column widths for alignment
  max_num_width = tag_counts.length.to_s.length
  max_tag_width = tag_counts.map { |tag, _| tag.length }.max || 0
  max_count_width = tag_counts.map { |_, count| count.to_s.length }.max || 0
 
  # Print numbered, aligned output
  tag_counts.each_with_index do |(tag, count), index|
  num = (index + 1).to_s.rjust(max_num_width)
  tag_col = tag.ljust(max_tag_width)
  count_col = count.to_s.rjust(max_count_width)
  puts "#{num}. #{tag_col}  #{count_col}"
  end
end
 
exit 0
EOT
 
Key enhancements when a tag is specified:
1. For each timestamp:
   - Reads the corresponding question file ('db/txt/question_<datetime>.txt')
   - Processes content by splitting lines and joining with spaces (matching 'questions.rb' behavior)
   - Handles missing question files gracefully
2. Formats output as: '[right-aligned-number]. [YY-MM-DD HH:MM] [question-content]'
3. Truncates long lines to fit terminal width (using 'term.cols')
4. Maintains right-aligned numbering and newest-first ordering
 
Example output:
 
$ bin/tags.rb emacs
1. 26-07-09 07:30 What is the best way to configure Emacs for Ruby development?
2. 26-06-15 14:22 How do I enable line numbers in Emacs?
3. 26-05-20 09:15 Is there a way to sync Emacs settings across machines?
 
31. Pi Harness Prompt
---------------------
 
As of 2026-07-31:
 
'''
You are an expert coding assistant operating inside pi, a coding agent
harness. You help users by reading files, executing commands, editing code, and
writing new files.
 
Available tools:
read
bash
edit
write
 
In addition to the tools above, you may have access to other custom tools
depending on the project.
 
Guidelines:
Use read to examine files instead of cat or sed.
Use bash for file operations like ls, rg, find.
Use edit for precise and single changes.
Use multiple edits for multiple changes in one file.
Use write only for new files or complete rewrites.
 
Pi documentation (read only when the user asks about pi itself, its SDK,
extensions, themes, skills, or TUI):
- Main documentation: ...
- Additional docs: ...
- Examples: ... (extensions, custom tools, SDK)
- When reading pi docs or examples, resolve docs/... under Additional docs and
  examples/... under Examples, not the current working directory
- When asked about: extensions (docs/extensions.md, examples/extensions/),
  themes (docs/themes.md), skills (docs/skills.md), prompt templates
  (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings
  (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers
  (docs/custom-provider.md), adding models (docs/models.md), pi packages
  (docs/packages.md), environment variables (docs/environment-variables.md)
- When working on pi topics, read the docs and examples, and follow .md
  cross-references before implementing
- Always read pi .md files completely and follow links to related docs (e.g.,
  tui.md for TUI API details)
'''
 
32. ToDo
--------
 
. Print answer does not find pi text answers, as it is looking for a markdown
  file.
. Edit in pi sessions should keep their changes in the answer file.
  . How do pi edits work?
. The today script has been taken out of another script and now is nowhere
  used. We wait, till it will be used again, otherwise it needs to be removed.
. Make llama_call invoke servers other than locally and on port 8080.
. Invoke llama and llama_call from gossip or ask?
 
33. Links
---------
 
Two upstream catalogs are worth keeping open in a browser tab whenever you
extend Gossip with a new model or sanity-check a price tag:
 
https://openrouter.ai/models
 
OpenRouter's full, live model index. Lists every model the gateway can
route to — commercial, closed-weight, and open-weight alike — together
with each model's release date (typically one to two days later than the
upstream provider's own announcement) and the current per-million-token
price for input and output. Use this page to discover a new model,
confirm its exact OpenRouter model id (the value you pass to
`bin/ask <modelid>`), and copy a provider hint into
`db/csv/openrouter_models.csv`.
 
https://huggingface.co/models?sort=trending&search=gguf
 
Hugging Face's trending GGUF models, ordered by popularity. This is the
place to look when you want a fresh open-weight model to run locally via
the llama.cpp backend (`bin/llama <model.gguf>` or
`bin/start_server.sh <model.gguf>`). The trending view surfaces the
community's current favourites — the candidates most likely to give
surprisingly strong results on local hardware.