Skip to content
· 26 min read
One prompt: paste this into Gemini CLI and your sessions auto-archive to Fresh Jots

One prompt: paste this into Gemini CLI and your sessions auto-archive to Fresh Jots

When you bill a client for AI-assisted work, "trust me, I did this" is a weak position in a dispute. The strong position is a timestamped, tamper-evident record of every session — captured automatically, so you never have to remember to save it.

This is the Google Gemini CLI version of our Claude Code auto-archive setup. You paste one prompt into Gemini CLI, answer a couple of questions, and from then on every Gemini CLI session is written to a local backup and pushed to your Fresh Jots account as a new append-only note in an `ai_sessions` folder — hashed on arrival, independently trusted-timestamped, and anchored to Bitcoin by Fresh Jots once a day.

1. What you need

- Gemini CLI installed and working. The prompt wires two hooks into Gemini CLI's own hooks system (`SessionEnd` and `PreCompress`, documented in the hooks reference, so a build that ships hooks is required. Hooks are enabled by default; `/hooks panel` inside Gemini CLI shows them.
- A Fresh Jots account. Free to start, no card. Plain-notes accounts get a built-in `ai_sessions` folder, and the prompt creates one if yours is missing.
- An API token (`mn_…`). Sign up and pick the "Plain notes" onboarding mode for a 14-day Dev trial token, or create one under Settings → API tokens. The prompt walks you through getting and wiring it.
- `jq` and `curl` on your PATH (the prompt tells you how to install them if not); `openssl` is optional and only adds the client-side timestamp.

2. Why Gemini CLI specifically?

This automation uses Gemini CLI's own `SessionEnd` hook (fires when the CLI exits or you run `/clear`) and `PreCompress` hook (fires just before a long conversation is summarised to save tokens), both configured in `~/.gemini/settings.json`, and it reads the session transcript Gemini CLI hands the hook on stdin as `transcript_path`. So the prompt on this page is Gemini-only. Fresh Jots itself isn't — on any other assistant you get the same "every session saved as a note" result over MCP (nothing to install) or with a couple of REST API calls. On Claude Code or Codex? Use the Claude Code prompt or the Codex prompt instead.

3. About your token

The prompt is careful with your `mn_…` token: it checks whether one is already present without ever printing the value, verifies it with a single API call, and stores it in an owner-only file, `~/.gemini/freshjots-token` (mode 600), besides the usual `export FRESHJOTS_TOKEN="…"` in your shell profile, after showing you the diff. The file matters for Gemini CLI in particular: its optional environment-variable redaction (`security.environmentVariableRedaction.enabled`, off by default) strips every variable whose name contains `TOKEN` from a hook's environment, so a hook that relied on the shell export alone would silently stop working the day you switch that on. The hook reads the environment first and the file second. The prompt never writes the token into the current project and never echoes it back into the chat.

For a smooth experience, make sure you apply the bare essentials: Two easy steps to set up everything, and get you going, or just Get your Fresh Jots API token, then set it once.

4. The prompt

Paste the block below into a Gemini CLI session and let it drive. It's idempotent — run it again later and each step detects existing state and no-ops.

Best to grab the byte-perfect copy. When rendered blog text is copied, a straight quote can turn into a curly one, a space can turn into a non-breaking one, or a line break can be lost — any of which breaks the JSON and bash inside this prompt. Grab the canonical source instead: download the prompt as a `.txt` file — same content, no rendering pipeline in between, easier, positively correct formatting (that you may miss if you copy/paste it manually).

One spot in the prompt is marked **VERIFY**: the layout of your session transcript file. The script follows the layout Gemini CLI's own chat-recording code writes (JSON Lines under `~/.gemini/tmp/<project>/chats/`), and the prompt ends with a dry run against the very session you paste it into, so Gemini CLI checks its own output before anything is posted. If the layout ever differs, the script falls back to archiving the raw transcript, so nothing is dropped. Honest caveat: this prompt was built from Gemini CLI's documentation and source and tested against transcripts shaped like them, not on a live Gemini CLI install; the dry run is the step that confirms it on yours.
I want you to set up Fresh Jots auto-archiving for my Gemini CLI sessions, end to end. Walk me through it conversationally; ask me only what you genuinely can't figure out yourself. Be safe: show diffs before writing any file in my home directory, and never write my API token to any file in the current project or echo it back to me after I paste it.

You are Gemini CLI, so you can inspect your own configuration and session files directly — use that. This prompt follows the Gemini CLI hooks reference (geminicli.com/docs/hooks/reference) and the CLI's own chat-recording code; one thing is marked "VERIFY" because it can differ by version: the exact layout of your session transcript file. Everything else (hook names, the stdin payload, the settings schema, the Fresh Jots API calls) is fixed and correct as written.

End state we're aiming for:
- Two hooks (SessionEnd and PreCompress) wired in ~/.gemini/settings.json, both invoking ~/.gemini/hooks/freshjots-gemini-sessions.sh.
- That script (full content in step 3 below) reads the session transcript Gemini CLI names on stdin (`transcript_path`), stashes a plain-text rendering locally at ~/.gemini/freshjots-stash/gemini-cli-YYYY-MM-DD-session-HH-MM-SS.txt, trusted-timestamps the exact bytes with an RFC 3161 authority (best-effort, via openssl), then POSTs it to Fresh Jots as a new append-only note inside an "ai_sessions" folder (a transcript over the plan's per-note cap — 3 MB on the Dev trial and the Dev plan — is split into ordered "part i of n" notes so it always posts in full, never dropped on a size limit). Each note starts with a two-line header (`Gemini CLI session: <id>` + `Date:`) so it self-identifies which session it was. It rotates the stash to the 500 most-recent files.
- My Fresh Jots token stored in ~/.gemini/freshjots-token (mode 600) and exported as FRESHJOTS_TOKEN from my shell profile (~/.zshrc if my $SHELL ends in zsh, otherwise ~/.bashrc). The script accepts either; the file is what makes the hook work even when Gemini CLI's environment-variable redaction (security.environmentVariableRedaction.enabled, off by default) strips every variable whose name contains TOKEN from the hook's environment.

Idempotent by design: if I re-run this prompt later, every step should detect existing state (token file present, script unchanged, hook entries already present, folder already created) and no-op. Tell me which steps you skipped and why.

Step 1 — Confirm intent. Tell me in one sentence what you're about to do. Ask me to type "yes" before you touch any file. After I've confirmed once, you can proceed through the remaining steps without asking again unless you hit a destructive edit you can't reverse.

Step 2 — Token bootstrap.
- Check presence without printing the value: `[ -s "$HOME/.gemini/freshjots-token" ] && echo file || { [ -n "${FRESHJOTS_TOKEN:-}" ] && echo env || echo unset; }`. Use that output, never `cat` the file or `echo $FRESHJOTS_TOKEN` (those would dump the value into the transcript).
- **If "file"**: verify with `curl -sS -o /dev/null -w '%{http_code}' --max-time 15 -H "Authorization: Bearer $(cat "$HOME/.gemini/freshjots-token")" https://freshjots.com/api/v1/folders`. The shell expands the substitution at runtime, so the value never appears in stdout, stderr, or the transcript. Expect 200. On 401, tell me the stored token is rejected and ask me for a fresh one (then continue as "unset"). On 200, jump to step 3.
- **If "env"** (the token was exported when Gemini CLI launched but no file exists yet): verify the same way with `-H "Authorization: Bearer $FRESHJOTS_TOKEN"`, then write the file: `umask 077 && printf '%s\n' "$FRESHJOTS_TOKEN" > "$HOME/.gemini/freshjots-token"`. Do NOT touch my shell profile; the variable is already wired in.
- **If "unset"**, tell me: "Go to https://freshjots.com, sign up (free, no card). At onboarding, pick the 'Plain notes' mode (its card mentions a 14-day free API trial) — that gets you a 14-day Dev trial token automatically. If you already have a Dev account, Settings → API tokens → Create token. Either way, you'll end up with an `mn_...` string. Paste it here when you have it."
- Once I paste a token, verify it with one curl call, substituting the literal `mn_...` value into the Authorization header (this single command is the only place the value appears outside the token file and my shell profile — unavoidable for the paste-in-chat flow): `curl -sS -o /dev/null -w '%{http_code}' --max-time 15 -H "Authorization: Bearer mn_THE_VALUE_I_PASTED" https://freshjots.com/api/v1/folders`. Expect 200. On 401, tell me politely "that token didn't work — paste another?" and retry up to 3 times.
- After verification: write the token file with `umask 077 && printf '%s\n' "mn_..." > "$HOME/.gemini/freshjots-token"` (mode 600, owner-only), then append `export FRESHJOTS_TOKEN="mn_..."` to my shell profile (~/.zshrc if my $SHELL ends in zsh, otherwise ~/.bashrc). Show me the diff before writing the profile. If `grep -l FRESHJOTS_TOKEN ~/.bashrc ~/.zshrc ~/.profile 2>/dev/null` finds it elsewhere already, skip the profile edit and tell me where it lives.
- Never write the raw token to any file in the current repo, and never echo it back into the chat after that one verify call. From this step on, refer to it only as `$FRESHJOTS_TOKEN` or the token file in any bash command — let the shell expand it.

Step 3 — Hook script. Write the script below to ~/.gemini/hooks/freshjots-gemini-sessions.sh (create the directory if missing), then `chmod +x` it. If the file already exists with identical content, skip. If it exists with different content, show me the diff and ask before overwriting.

VERIFY (transcript layout) after writing the script: the hooks reference describes `transcript_path` only as "the absolute path to session transcript JSON". Gemini CLI's chat-recording service (packages/core/src/services/chatRecordingService.ts) writes it as JSON Lines under ~/.gemini/tmp/<project-hash>/chats/session-<timestamp>-<id>.jsonl: one record per line, where a message record carries `type` ("user" or "gemini"), `timestamp`, `content` (a string or an array of Gemini parts with `text`, `functionCall` or `functionResponse`) and, on gemini messages, `toolCalls` (each with `name`, `input`, `result`); the other line shapes are bookkeeping records keyed `$set`, `$patch` or `$rewindTo`. Older builds wrote one JSON document with the same `messages` array. The flatten below handles both and skips the bookkeeping lines. Confirm against a real file: run the dry run described in step 6 and check that the rendered text under ~/.gemini/freshjots-stash contains your prompts and the model's replies; adjust the `records`, `pick` or `role_of` definitions only if conversation text is missing. The raw-transcript fallback archives the file as-is whenever the render fails or comes out empty, so nothing is ever lost.

Script content (write this verbatim, no edits, no improvements):

#!/usr/bin/env bash
# Auto-archive each Gemini CLI session as a new Fresh Jots note inside
# the ai_sessions folder, with a rolling 500-file local stash as a
# fallback for offline / API-down moments.
# Wired in ~/.gemini/settings.json for the SessionEnd event (fires when the
# CLI exits or the session is cleared) and PreCompress (fires just before a
# long conversation is summarised to save tokens, so nothing is lost).
# Always exits 0 and prints "{}" (the hooks contract wants JSON on stdout);
# failures are logged, never block the user.

set -uo pipefail

STASH_DIR="$HOME/.gemini/freshjots-stash"
LOG_FILE="$STASH_DIR/.log"
FOLDER_ID_FILE="$STASH_DIR/.folder-id"
TOKEN_FILE="$HOME/.gemini/freshjots-token"
FOLDER_NAME="ai_sessions"
KEEP=500
# RFC 3161 trusted-timestamp authority. The hook stamps each note's exact bytes
# here client-side (the TSA sees only a SHA-256, never the content) so the note
# carries an immediate, independent "existed as-is at time T" proof beside the
# server's daily Bitcoin anchor. Overridable; DigiCert's public TSA needs no
# signup and chains to a widely-trusted CA.
TSA_URL="${FRESHJOTS_TSA_URL:-http://timestamp.digicert.com}"
# FRESHJOTS_DRY_RUN=1 renders and stashes but makes no network call — the way
# to verify the transcript layout (step 6) without posting a test note.
DRY_RUN="${FRESHJOTS_DRY_RUN:-0}"

mkdir -p "$STASH_DIR"
log() { printf '[%s] %s\n' "$(date -Iseconds)" "$*" >> "$LOG_FILE"; }
finish() { echo '{}'; exit 0; }

# Gemini CLI pipes one JSON object to the hook on stdin. Every hook receives
# session_id, transcript_path, cwd, hook_event_name and timestamp; SessionEnd
# adds reason (exit|clear|logout|prompt_input_exit|other), PreCompress adds
# trigger (auto|manual).
INPUT=$(cat)
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // empty')
TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
log "FIRED event='$EVENT' session='$SESSION_ID'"

[ -z "$SESSION_ID" ] && { log "ABORT: no session_id"; finish; }

# Token: the environment variable when the CLI passed it through, else the
# owner-only token file. Gemini CLI's optional environment redaction strips
# every variable whose name contains TOKEN, so the file is the dependable path.
if [ -z "${FRESHJOTS_TOKEN:-}" ] && [ -s "$TOKEN_FILE" ]; then
    FRESHJOTS_TOKEN=$(tr -d '[:space:]' < "$TOKEN_FILE")
fi
[ -z "${FRESHJOTS_TOKEN:-}" ] && [ "$DRY_RUN" != "1" ] && { log "ABORT: no token (FRESHJOTS_TOKEN unset and $TOKEN_FILE missing)"; finish; }

# Fall back to the newest session file under ~/.gemini/tmp if the payload
# didn't carry a usable transcript_path (older builds, or a moved file).
# The file name carries the first 8 characters of the session id
# (session-<timestamp>-<id8>.jsonl), so try that match first, then the newest.
if [ -z "$TRANSCRIPT" ] || [ ! -s "$TRANSCRIPT" ]; then
    TRANSCRIPT=$(find "$HOME/.gemini/tmp" -type f -path "*/chats/*" -name "*${SESSION_ID:0:8}*" 2>/dev/null \
        | xargs -r ls -1t 2>/dev/null | head -1)
fi
if [ -z "$TRANSCRIPT" ] || [ ! -s "$TRANSCRIPT" ]; then
    TRANSCRIPT=$(find "$HOME/.gemini/tmp" -type f -path "*/chats/*" \
        \( -name "session-*.jsonl" -o -name "session-*.json" \) 2>/dev/null \
        | xargs -r ls -1t 2>/dev/null | head -1)
fi
if [ -z "$TRANSCRIPT" ] || [ ! -s "$TRANSCRIPT" ]; then
    log "ABORT: could not locate transcript for session_id=$SESSION_ID"
    finish
fi

# Title: date + HH-MM-SS — sortable per day and collision-free. (A per-day
# count would stick once the 500-file rotation caps the file count, so
# every later session that day would reuse the same number and overwrite;
# a wall-clock timestamp avoids that failure mode.)
TITLE="gemini-cli-$(date +%Y-%m-%d)-session-$(date +%H-%M-%S)"
STASH_PATH="$STASH_DIR/${TITLE}.txt"

# Flatten the transcript -> role-prefixed plain text. Accepts Gemini CLI's
# JSON Lines session file (message records with type user|gemini, content as
# a string or an array of parts, toolCalls with name/input/result; the $set,
# $patch and $rewindTo bookkeeping lines are skipped) and the older single
# JSON document with a messages array. Emits "[timestamp] ROLE:\ntext".
# clean() strips re-injected system blocks; cap() bounds tool payloads;
# prose is never truncated; thought parts are omitted. On any jq error or
# empty output the raw-transcript fallback below archives the file as-is.
jq -r '
def clean: gsub("<system-reminder>[\\s\\S]*?</system-reminder>"; "");
def cap($n): if (.|length) > $n then (.[0:$n]) + "\n…[truncated " + (((.|length) - $n)|tostring) + " chars]" else . end;
def pick: (.text // .content // .parts // .message // .payload // .output // .result // .functionCall // .function_call // .functionResponse // .response // null);
def text_of:
  if type == "string" then .
  elif type == "number" or type == "boolean" then tostring
  elif type == "array" then ([.[] | text_of] | map(select(. != "")) | join("\n"))
  elif type == "object" then
    ( if (.thought // false) == true then ""
      elif (.type // "") == "tool_use" or (has("name") and (has("args") or has("arguments") or has("input"))) then
        "[Tool: " + ((.name // .displayName // "?")|tostring) + "] " + ((.args // .arguments // .input // {}) | tostring | cap(1500))
      elif (.type // "") == "tool_result" then
        "TOOL RESULT:\n" + ((.content // .output // "") | text_of | cap(4000))
      elif has("response") and (has("name") or has("id")) then
        "TOOL RESULT:\n" + (.response | tostring | cap(4000))
      elif has("inlineData") or has("fileData") or (.type // "") == "image" then "[binary content omitted]"
      else (pick | if . == null then "" else text_of end)
      end )
  else "" end;
def role_of: ((.role // .author // .type // "?") | tostring | ascii_upcase);
def result_block: (text_of | cap(4000) | if startswith("TOOL RESULT:") then . else "TOOL RESULT:\n" + . end);
def tool_calls_of:
  ((.toolCalls // []) | map(
      "[Tool: " + ((.name // .displayName // "?")|tostring) + "] " + ((.input // .args // {}) | tostring | cap(1500))
      + (if (.result // null) != null then "\n" + (.result | result_block) else "" end)
    ) | join("\n"));
def without_call_parts:
  if (.content | type) == "array" then .content |= map(select((type == "object" and has("functionCall")) | not)) else . end;
def records:
  if type == "array" then .[]
  elif type == "object" then
    ( if has("$set") or has("$patch") or has("$rewindTo") then empty
      elif has("messages") then .messages[]
      elif has("history")  then .history[]
      elif has("turns")    then .turns[]
      elif has("items")    then .items[]
      elif has("events")   then .events[]
      else . end )
  else empty end;
[ records
  | select(type == "object")
  | . as $m
  | (($m.timestamp // $m.ts // $m.time // $m.created_at // "") | tostring) as $ts
  | (if ($m.message | type) == "object" then $m.message else $m end) as $body_src
  | ($body_src | role_of) as $role
  | (if (($m.toolCalls // []) | length) > 0 then ($body_src | without_call_parts) else $body_src end) as $body
  | ([ ($body | text_of), ($m | tool_calls_of) ] | map(select(. != "")) | join("\n")) as $text
  | select(($text | length) > 0)
  | (if $ts == "" then "" else "[" + $ts + "] " end) + $role + ":\n" + $text + "\n"
] | join("\n") | clean | select(length > 0)
' "$TRANSCRIPT" > "$STASH_PATH" 2>>"$LOG_FILE"
render_status=$?

# Fall back to the raw transcript only if the structured render actually failed
# (jq parse/runtime error) or produced an empty file — not on a byte count. A
# short but valid session renders to very few bytes and is still correct; a
# size threshold here would wrongly discard it and dump raw JSON in its place.
if [ "$render_status" -ne 0 ] || [ ! -s "$STASH_PATH" ]; then
    cp "$TRANSCRIPT" "$STASH_PATH"
fi
printf '\n==================== TRANSCRIPT END · %s ====================\n' \
    "$(date -Iseconds)" >> "$STASH_PATH"

# Prepend a session header so the archived note self-identifies which Gemini
# CLI session it is — the id traces back to the source file under
# ~/.gemini/tmp. Done after the footer so it survives both the structured
# render and the raw fallback path.
{ printf 'Gemini CLI session: %s\nDate: %s\n\n' \
    "$SESSION_ID" "$(date '+%Y-%m-%d %H:%M:%S')"; cat "$STASH_PATH"; } \
    > "${STASH_PATH}.tmp" && mv "${STASH_PATH}.tmp" "$STASH_PATH"
log "STASHED $STASH_PATH ($(wc -c < "$STASH_PATH" | tr -d ' ') bytes) from $TRANSCRIPT"

if [ "$DRY_RUN" = "1" ]; then
    log "DRY RUN: not posting; inspect $STASH_PATH"
    finish
fi

# Find-or-create the ai_sessions folder. Cache the id so we don't round-trip
# the lookup on every session. The name match is case-insensitive to mirror
# the server's LOWER(name) uniqueness rule.
folder_id=""

# Read the per-note cap once (used for MAX_BYTES below).
LIMITS_JSON=$(curl -sS --max-time 15 -H "Authorization: Bearer $FRESHJOTS_TOKEN" \
    "https://freshjots.com/api/v1/limits" 2>>"$LOG_FILE" || true)

if [ -s "$FOLDER_ID_FILE" ]; then
    cached=$(cat "$FOLDER_ID_FILE")
    status=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' \
        -H "Authorization: Bearer $FRESHJOTS_TOKEN" \
        "https://freshjots.com/api/v1/folders/$cached")
    [ "$status" = "200" ] && folder_id=$cached
fi

if [ -z "$folder_id" ]; then
    folder_id=$(curl -sS --max-time 15 \
        -H "Authorization: Bearer $FRESHJOTS_TOKEN" \
        "https://freshjots.com/api/v1/folders" \
      | jq -r --arg n "$FOLDER_NAME" \
            '.folders[]? | select((.name // "" | ascii_downcase) == ($n | ascii_downcase)) | .id' | head -1)
fi

if [ -z "$folder_id" ]; then
    folder_id=$(curl -sS --max-time 15 \
        -H "Authorization: Bearer $FRESHJOTS_TOKEN" \
        -H "Content-Type: application/json" \
        -X POST -d "{\"folder\":{\"name\":\"$FOLDER_NAME\"}}" \
        "https://freshjots.com/api/v1/folders" \
      | jq -r '.id // empty')
fi

if [ -n "$folder_id" ]; then
    echo "$folder_id" > "$FOLDER_ID_FILE"
else
    log "WARN: could not resolve/create folder '$FOLDER_NAME'; posting at root"
fi

# Compute an RFC 3161 trusted-timestamp token (base64, single line) over $1's
# EXACT bytes via openssl + the configured TSA. Best-effort: echoes the token on
# success and nothing on any failure (openssl absent, TSA down, timeout), so the
# archive still posts untokenized. The digest is SHA-256(file) — identical to
# the note's server-side create-leaf content_hash — and the TSA sees only that
# hash, never the transcript.
tsa_token_for() {
    local file="$1" dir tsq tsr
    command -v openssl >/dev/null 2>&1 || { log "TSA: openssl not found; posting without token"; return 0; }
    dir=$(mktemp -d) || return 0
    tsq="$dir/req.tsq"; tsr="$dir/resp.tsr"
    if ! openssl ts -query -data "$file" -sha256 -cert -out "$tsq" 2>>"$LOG_FILE"; then
        log "TSA: ts -query failed for $(basename "$file"); posting without token"; rm -rf "$dir"; return 0
    fi
    if ! curl -sS --max-time 20 -H 'Content-Type: application/timestamp-query' \
            --data-binary "@$tsq" "$TSA_URL" -o "$tsr" 2>>"$LOG_FILE" || [ ! -s "$tsr" ]; then
        log "TSA: query to $TSA_URL failed; posting without token"; rm -rf "$dir"; return 0
    fi
    base64 < "$tsr" | tr -d '\n'
    rm -rf "$dir"
}

# Post one body file as a single Fresh Jots note. Best-effort: logs the
# outcome, cleans its own temps, never aborts the hook. The local stash
# at $STASH_PATH survives regardless (rotation keeps the last 500) so a
# failed POST is recoverable.
post_note() {
    local title="$1" body_file="$2"
    local payload response status tsa_token
    payload=$(mktemp); response=$(mktemp)
    tsa_token=$(tsa_token_for "$body_file")
    # Single jq build: folder_id and the TSA token are each included only when
    # present, so an offline TSA (empty token) posts exactly as before.
    jq -Rs --arg title "$title" --arg fid "$folder_id" --arg tok "$tsa_token" --arg tsaurl "$TSA_URL" '
        {note: (
            {title: $title, plain_body: ., format: "plain", append_only: true}
            + (if $fid != "" then {folder_id: ($fid | tonumber)} else {} end)
            + (if $tok != "" then {tsa_token: $tok, tsa_url: $tsaurl} else {} end)
        )}' < "$body_file" > "$payload"
    status=$(curl -sS -o "$response" -w '%{http_code}' --max-time 30 \
        -X POST https://freshjots.com/api/v1/notes \
        -H "Authorization: Bearer $FRESHJOTS_TOKEN" \
        -H "Content-Type: application/json" \
        --data-binary "@$payload" 2>>"$LOG_FILE")
    case "$status" in
        201) log "SUCCESS: created note #$(jq -r '.id // "?"' < "$response") '$title' folder_id=$folder_id" ;;
        *)   log "FAILURE: status=$status title='$title' body=$(head -c 256 "$response" 2>/dev/null); LOCAL STASH at $STASH_PATH" ;;
    esac
    rm -f "$payload" "$response"
}

# Per-chunk byte ceiling. split -C chunks the rendered transcript by bytes, so
# a chunk's size IS the decoded plain_body the API checks. Two ceilings
# bound it: (1) the per-note cap on the decoded body, which GET /api/v1/limits
# reports as plain_body_bytes (3 MB on the Dev trial and the Dev plan), and
# (2) the rack_attack pre-parse blocklist on the raw *request* Content-Length
# (4 MB), which sees the JSON-escaped body plus the envelope. 90% of (1)
# leaves room for the per-part header, and even 3 MB × 0.9 at a pessimistic
# ~45% escaping inflation stays under (2). When the limits call fails, fall
# back to 900,000 bytes, which is safe on every plan. When the rendered
# transcript exceeds the ceiling, split -C divides it at line boundaries into
# ordered parts (aa, ab, ... -> sorted glob = correct order) and every part is
# posted as "<title> (part i of n)". The full transcript is always written
# across 1..n notes, never dropped on a 413.
cap_bytes=$(printf '%s' "$LIMITS_JSON" | jq -r '.plain_body_bytes // empty' 2>/dev/null)
MAX_BYTES=900000
case "$cap_bytes" in
    ''|*[!0-9]*) ;;
    *) [ "$cap_bytes" -gt 0 ] && MAX_BYTES=$(( cap_bytes * 9 / 10 )) ;;
esac
TOTAL_BYTES=$(wc -c < "$STASH_PATH" | tr -d ' ')

if [ "$TOTAL_BYTES" -le "$MAX_BYTES" ]; then
    post_note "$TITLE" "$STASH_PATH"
else
    SPLIT_PREFIX="$STASH_DIR/.split-${SESSION_ID}."
    rm -f "${SPLIT_PREFIX}"* 2>/dev/null
    trap 'rm -f "${SPLIT_PREFIX}"*' EXIT
    split -C "$MAX_BYTES" -- "$STASH_PATH" "$SPLIT_PREFIX"
    parts=( "${SPLIT_PREFIX}"* )
    n=${#parts[@]}
    log "SPLIT: $TOTAL_BYTES bytes > $MAX_BYTES -> $n part(s)"
    i=1
    for part in "${parts[@]}"; do
        hdr=$(mktemp)
        { printf '%s — part %d of %d\n\n' "$TITLE" "$i" "$n"; cat "$part"; } > "$hdr"
        post_note "$TITLE (part $i of $n)" "$hdr"
        rm -f "$hdr"
        i=$((i + 1))
    done
    rm -f "${SPLIT_PREFIX}"*
fi

# Rotate the stash: keep the 500 most-recently-modified .txt files.
( cd "$STASH_DIR" && ls -1t *.txt 2>/dev/null | tail -n +$((KEEP + 1)) | while IFS= read -r f; do rm -f -- "$f"; done )

finish

Step 4 — settings.json. Register the SessionEnd and PreCompress hooks in ~/.gemini/settings.json (the same file that holds your other Gemini CLI settings). If the file doesn't exist, create it with just this hooks block (substitute my actual $HOME — JSON doesn't expand ~):

{
  "hooks": {
    "SessionEnd": [
      {
        "matcher": "*",
        "hooks": [
          { "name": "freshjots-archive", "type": "command", "command": "bash <HOME>/.gemini/hooks/freshjots-gemini-sessions.sh", "timeout": 120000 }
        ]
      }
    ],
    "PreCompress": [
      {
        "matcher": "*",
        "hooks": [
          { "name": "freshjots-archive", "type": "command", "command": "bash <HOME>/.gemini/hooks/freshjots-gemini-sessions.sh", "timeout": 120000 }
        ]
      }
    ]
  }
}

This is the schema the hooks reference documents: each event maps to an array of matcher groups, each with a nested "hooks" array of { "name", "type": "command", "command", "timeout" }. "matcher" is an exact string for lifecycle events and "*" matches every SessionEnd reason (exit, clear, logout, prompt_input_exit, other) and every PreCompress trigger (auto, manual). "timeout" is in milliseconds; the default 60000 would cut a multi-part upload short, hence 120000. Hooks are enabled by default (hooksConfig.enabled defaults to true); if `/hooks panel` shows the hook disabled, run `/hooks enable freshjots-archive`.

If the file already exists, read it with jq and append our entry to .hooks.SessionEnd[] and .hooks.PreCompress[] (creating those arrays if absent) without disturbing any other key. Do NOT duplicate: if an entry whose command already matches `bash <HOME>/.gemini/hooks/freshjots-gemini-sessions.sh` is already in either array, leave it alone. Show me the diff before writing. Validate the result with `jq . ~/.gemini/settings.json > /dev/null`.

Step 5 — Folder bootstrap. Eagerly create the ai_sessions folder so I get a visible confirmation it worked before the first session ends:
- `GET https://freshjots.com/api/v1/folders` with the bearer token; look for a folder named "ai_sessions" (Fresh Jots creates a built-in one for plain-notes accounts, so it is usually already there).
- If absent: `POST https://freshjots.com/api/v1/folders` with body `{"folder":{"name":"ai_sessions"}}`.
- Persist the returned id (or the existing one) to ~/.gemini/freshjots-stash/.folder-id. `mkdir -p ~/.gemini/freshjots-stash` first.

Step 6 — Dry run against this very session, then tell me what to do next.
- Find this session's transcript: `ls -t ~/.gemini/tmp/*/chats/ | head -3` (the newest session-*.jsonl in the project's chats directory is this conversation). Then run the hook once without posting: `printf '{"session_id":"dryrun","hook_event_name":"SessionEnd","transcript_path":"<that path>"}' | FRESHJOTS_DRY_RUN=1 bash ~/.gemini/hooks/freshjots-gemini-sessions.sh`. Open the newest file in ~/.gemini/freshjots-stash and confirm it contains my prompts and your replies as "USER:" / "GEMINI:" blocks (not raw JSON). If it is raw JSON, the layout differs from the one described in step 3: show me one message line from the transcript and propose the one-line adjustment to `records`, `pick` or `role_of`.
- Then print a single clear three-line block:
  1. Quit Gemini CLI completely (end every running session). The SessionEnd hook fires on this exit and archives this session — the first auto-archived note.
  2. Reload your shell (`source ~/.zshrc` or `source ~/.bashrc`, depending on which I just edited). Skip if FRESHJOTS_TOKEN was already exported before this run.
  3. Relaunch Gemini CLI, ask anything trivial, then end the session with /quit (or /clear). SessionEnd fires on both, so both archive the session; PreCompress archives a snapshot just before a long conversation is compressed. Each archived note appears at freshjots.com in the ai_sessions folder.

Step 7 — Verification helpers. Tell me I can:
  - `tail -f ~/.gemini/freshjots-stash/.log` in another terminal to watch hooks fire in real time.
  - `/hooks panel` inside Gemini CLI to see the registered hooks and whether they are enabled.
  - `ls -1t ~/.gemini/freshjots-stash/` to see the rotating local stash.
  - `grep -lr "some-keyword" ~/.gemini/freshjots-stash/` to search recent sessions even when offline.

Constraints:
- Use absolute paths everywhere ($HOME expands; ~ inside JSON does not).
- Prefer jq over hand-rolled JSON manipulation.
- Treat every file edit as needing diff + my "yes" the first time, then proceed.
- If any API call returns non-200/201, stop and explain what happened — don't paper over it.
- If `jq` or `curl` is missing, tell me which package manager to use to install it (apt/dnf/brew/pacman based on what's on $PATH) and ask before installing.
- `openssl` is optional (used only to add a client-side RFC 3161 timestamp at upload): if it's missing, tell me which package manager to use (apt/dnf/brew/pacman based on what's on $PATH) and ask before installing, but still proceed if I decline — the note uploads without the local timestamp and is timestamped by Fresh Jots after upload.

Begin.

5. What the hook does

Once installed, a small bash script lives at `~/.gemini/hooks/freshjots-gemini-sessions.sh` and runs on Gemini CLI's `SessionEnd` and `PreCompress` events. Each time it:

- Reads the transcript Gemini CLI hands it (`transcript_path` on stdin) and flattens it to readable, role-prefixed plain text — your prompts and the model's replies kept in full, tool calls and their results trimmed, bookkeeping lines and hidden thoughts dropped.
- Stashes it locally at `~/.gemini/freshjots-stash/` and keeps a rolling 500 files, so even offline you have a searchable backup.
- Trusted-timestamps the exact bytes with an RFC 3161 authority (best-effort) for an immediate proof-of-existence.
- Posts it to Fresh Jots as a new append-only note in the `ai_sessions` folder, splitting a transcript over your plan's per-note cap into ordered parts so nothing is ever dropped on a size limit.
- Never blocks you — every failure is logged, the hook prints the `{}` Gemini CLI expects and exits cleanly.

6. Turn it on

After the prompt finishes: quit Gemini CLI (that first exit already archives the setup session), reload your shell if it edited your profile, then relaunch Gemini CLI, ask anything trivial, and end the session with `/quit` or `/clear`. Your first archived note appears in the `ai_sessions` folder at freshjots.com.

7. Verify it's working

- `tail -f ~/.gemini/freshjots-stash/.log` — watch the hook fire in real time.
- `/hooks panel` inside Gemini CLI — the registered hooks and whether they are enabled.
- `ls -1t ~/.gemini/freshjots-stash/` — the rotating local backups.
- `grep -lr "some-keyword" ~/.gemini/freshjots-stash/` — search recent sessions, even offline.

Want it on a different assistant, or no CLI at all? Connect any MCP client straight to your notes — see Connect Fresh Jots to any AI assistant over MCP.

For the bigger picture of what the API lets you do, see: Everything You Can Do Here.

Share this post

Ready to start taking better notes? Sign up free