Skip to content

Instantly share code, notes, and snippets.

@sn99
Last active July 20, 2026 18:51
Show Gist options
  • Select an option

  • Save sn99/fe29c55ad04b0fa54d2cb97da19deb9d to your computer and use it in GitHub Desktop.

Select an option

Save sn99/fe29c55ad04b0fa54d2cb97da19deb9d to your computer and use it in GitHub Desktop.
grok-turn-index: track which user prompt is on which turn in long Grok Build sessions

grok-turn-index

Track which user prompt is on which turn in long Grok Build TUI sessions.

Note: GitHub Gists cannot store directories, so files here use a flat naming scheme (scripts__list-turns, hooks__hooks.json, …). Run ./install.sh to expand them into a real plugin tree under ~/.grok/plugins/turn-index.

Why this exists

In long Grok sessions it’s easy to forget which prompt was on which turn. Grok’s hook UI only shows that a hook ran (e.g. stop [hooks: 1]), not custom hook stdout. This project therefore combines:

  1. Session indexing (JSONL + rebuild from updates.jsonl)
  2. /turns / /turn N slash commands
  3. Global AGENTS.md rules so the model prints a distinct markdown label:
### 🔴 Turn N

Quick install

git clone https://gist.github.com/fe29c55ad04b0fa54d2cb97da19deb9d.git grok-turn-index
cd grok-turn-index
./install.sh
grok plugin install ~/.grok/plugins/turn-index --trust

Enable in ~/.grok/config.toml:

[plugins]
enabled = ["turn-index"]

Reload hooks (/hooksr) or start a new session (needed for AGENTS.md).

Usage

Turns are 0-based (first user prompt is turn 0).

/turns          # list turn → prompt preview
/turn 0         # full text of turn 0 (first prompt)

After each reply you should see:

🔴 Turn 0

Also: Shift+Left / Shift+Right jumps user turns in scrollback (built-in).

Gist file map

Gist file Installs as
plugin.json plugin.json
scripts__* scripts/*
hooks__* hooks/*
commands__* commands/*
skills__turns__SKILL.md skills/turns/SKILL.md
AGENTS.md ~/.grok/AGENTS.md (global rules)

Optional notifications

After install, append ~/.grok/plugins/turn-index/config-snippet.toml to ~/.grok/config.toml.

Requirements

  • Grok Build CLI (0.2.x+)
  • bash, python3
  • optional: notify-send

License

MIT — see LICENSE

Local project

A nested (directory) copy for development lives at:

https://github.com/sn99 — or clone this gist and keep using install.sh.

Gist: https://gist.github.com/sn99/fe29c55ad04b0fa54d2cb97da19deb9d

Turn numbers (always on)

At the very end of every assistant reply, print a turn label so it is easy to spot when scrolling.

Exact format (required)

Use this markdown heading (no ANSI, no code fence):

🔴 Turn

Examples:

🔴 Turn 0

🔴 Turn 1 · hello

Rules:

  • <N> is the 0-based count of user messages in this session (first prompt → 0).
  • Print this as the last part of the reply (a blank line before the heading is fine).
  • No text after the turn heading.
  • Do not use ANSI color codes (they show up as garbage like [1;31m).
  • Do not wrap the label in a code fence.
  • Always use the ### 🔴 Turn N form so labels look the same and are easy to scan.

If ~/.grok/plugin-data/turn-index/CURRENT has a line like Turn 3 · …, use that number (and optional preview after ·).

description Show the full user prompt for a specific turn number
disable-model-invocation true
argument-hint <turn-number>

/turn

Show the full text of user turn $ARGUMENTS.

Instructions

  1. If $ARGUMENTS is empty or not a non-negative integer (0, 1, 2, …), ask for a turn number (hint: /turns).
  2. Run:
SCRIPT="${GROK_PLUGIN_ROOT:-$HOME/.grok/plugins/turn-index}/scripts/list-turns"
"$SCRIPT" "${GROK_SESSION_ID:-}" show $ARGUMENTS
  1. Paste the script output verbatim.
description List every user prompt with its turn number in this session
disable-model-invocation true
argument-hint

/turns

Show the turn index for the current Grok session.

Instructions

  1. Run (authoritative — do not invent turn numbers):
SCRIPT="${GROK_PLUGIN_ROOT:-$HOME/.grok/plugins/turn-index}/scripts/list-turns"
"$SCRIPT" "${GROK_SESSION_ID:-}" list
  1. Paste the script stdout to the user verbatim.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "__PLUGIN_ROOT__/scripts/rebuild-index",
"timeout": 15
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "__PLUGIN_ROOT__/scripts/on-prompt-submit",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "__PLUGIN_ROOT__/scripts/on-turn-stop",
"timeout": 10
}
]
}
]
}
}
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'ROOT=\"${GROK_PLUGIN_ROOT:-$HOME/.grok/plugins/turn-index}\"; exec \"$ROOT/scripts/rebuild-index\"'",
"timeout": 15
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'ROOT=\"${GROK_PLUGIN_ROOT:-$HOME/.grok/plugins/turn-index}\"; exec \"$ROOT/scripts/on-prompt-submit\"'",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'ROOT=\"${GROK_PLUGIN_ROOT:-$HOME/.grok/plugins/turn-index}\"; exec \"$ROOT/scripts/on-turn-stop\"'",
"timeout": 10
}
]
}
]
}
}
#!/usr/bin/env bash
# Install grok-turn-index into ~/.grok for Grok Build TUI.
# Supports nested project layout OR flat gist layout (scripts__name).
set -euo pipefail
SRC="$(cd "$(dirname "$0")" && pwd)"
GROK_HOME="${GROK_HOME:-$HOME/.grok}"
PLUGIN_DST="${GROK_HOME}/plugins/turn-index"
HOOKS_DST="${GROK_HOME}/hooks/turn-index.json"
AGENTS_DST="${GROK_HOME}/AGENTS.md"
stage="$(mktemp -d)"
cleanup() { rm -rf "$stage"; }
trap cleanup EXIT
echo "==> Assembling plugin tree"
mkdir -p "$stage"/{scripts,commands,hooks,skills/turns}
if [[ -d "$SRC/scripts" ]]; then
# Nested layout
cp -a "$SRC/plugin.json" "$stage/"
cp -a "$SRC/scripts/." "$stage/scripts/"
cp -a "$SRC/commands/." "$stage/commands/" 2>/dev/null || true
cp -a "$SRC/hooks/." "$stage/hooks/" 2>/dev/null || true
cp -a "$SRC/skills/." "$stage/skills/" 2>/dev/null || true
cp -a "$SRC/AGENTS.md" "$stage/" 2>/dev/null || true
cp -a "$SRC/README.md" "$stage/" 2>/dev/null || true
cp -a "$SRC/LICENSE" "$stage/" 2>/dev/null || true
else
# Flat gist layout: dir__path__file
cp "$SRC/plugin.json" "$stage/"
cp "$SRC/AGENTS.md" "$stage/" 2>/dev/null || true
cp "$SRC/README.md" "$stage/" 2>/dev/null || true
cp "$SRC/LICENSE" "$stage/" 2>/dev/null || true
for f in "$SRC"/scripts__*; do
[[ -e "$f" ]] || continue
cp "$f" "$stage/scripts/${f##*scripts__}"
done
for f in "$SRC"/commands__*; do
[[ -e "$f" ]] || continue
cp "$f" "$stage/commands/${f##*commands__}"
done
for f in "$SRC"/hooks__*; do
[[ -e "$f" ]] || continue
cp "$f" "$stage/hooks/${f##*hooks__}"
done
for f in "$SRC"/skills__*; do
[[ -e "$f" ]] || continue
# skills__turns__SKILL.md → skills/turns/SKILL.md
rel="${f##*skills__}"
rel="${rel//__//}"
mkdir -p "$stage/skills/$(dirname "$rel")"
cp "$f" "$stage/skills/$rel"
done
fi
chmod +x "$stage/scripts/"* 2>/dev/null || true
echo "==> Installing plugin → ${PLUGIN_DST}"
mkdir -p "${GROK_HOME}/plugins" "${GROK_HOME}/hooks" "${GROK_HOME}/plugin-data/turn-index"
rm -rf "${PLUGIN_DST}"
mkdir -p "${PLUGIN_DST}"
cp -a "$stage"/. "${PLUGIN_DST}/"
echo "==> Writing global hooks → ${HOOKS_DST}"
TEMPLATE="${PLUGIN_DST}/hooks/global-hooks.template.json"
if [[ -f "$TEMPLATE" ]]; then
sed "s|__PLUGIN_ROOT__|${PLUGIN_DST}|g" "$TEMPLATE" > "${HOOKS_DST}"
else
# Fallback portable plugin-root form
cat > "${HOOKS_DST}" << HOOKS
{
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "${PLUGIN_DST}/scripts/rebuild-index", "timeout": 15}]}],
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "${PLUGIN_DST}/scripts/on-prompt-submit", "timeout": 5}]}],
"Stop": [{"hooks": [{"type": "command", "command": "${PLUGIN_DST}/scripts/on-turn-stop", "timeout": 10}]}]
}
}
HOOKS
fi
echo "==> Installing AGENTS.md turn-label rules → ${AGENTS_DST}"
if [[ -f "${AGENTS_DST}" ]] && ! grep -q 'Turn numbers (always on)' "${AGENTS_DST}" 2>/dev/null; then
{
echo ""
echo "<!-- begin turn-index -->"
cat "${PLUGIN_DST}/AGENTS.md"
echo "<!-- end turn-index -->"
} >> "${AGENTS_DST}"
echo " (appended to existing AGENTS.md)"
elif [[ -f "${PLUGIN_DST}/AGENTS.md" ]]; then
cp "${PLUGIN_DST}/AGENTS.md" "${AGENTS_DST}"
fi
cat > "${PLUGIN_DST}/config-snippet.toml" << SNIP
# Optional: append to ~/.grok/config.toml
[ui.notifications]
condition = "always"
events = ["turn_complete", "approval_required"]
[[ui.notifications.hooks]]
command = "${PLUGIN_DST}/scripts/notify-turn"
events = ["turn_complete"]
only_unfocused = false
timeout_secs = 5
SNIP
echo ""
echo "Install complete."
echo ""
echo "Next steps:"
echo " 1. grok plugin install ${PLUGIN_DST} --trust"
echo " 2. Ensure [plugins].enabled includes \"turn-index\" in ~/.grok/config.toml"
echo " 3. /hooks → r (or start a new Grok session)"
echo " 4. /turns and /turn <n>"
echo " 5. Replies should end with: ### 🔖 Turn N"
MIT License
Copyright (c) 2026 sn99
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "turn-index",
"version": "1.4.0",
"description": "Track which user prompt is on which turn in long Grok sessions. /turns, /turn N, auto labels, and resume-safe indexing.",
"author": {
"name": "sn99"
},
"keywords": [
"grok",
"turns",
"prompts",
"session",
"navigation",
"index"
],
"license": "MIT"
}
#!/usr/bin/env bash
# Shared helpers for turn-index plugin.
turn_index_data_dir() {
if [[ -n "${GROK_PLUGIN_DATA:-}" ]]; then
printf '%s' "$GROK_PLUGIN_DATA"
else
printf '%s' "${HOME}/.grok/plugin-data/turn-index"
fi
}
# Ordered candidate data roots (write uses the first; read may scan).
turn_index_data_dirs() {
local seen=""
local d
for d in \
"${GROK_PLUGIN_DATA:-}" \
"${HOME}/.grok/plugin-data/turn-index" \
"${HOME}/.grok/plugin-data/turn-index-9cd77831" \
; do
[[ -z "$d" ]] && continue
case " $seen " in
*" $d "*) continue ;;
esac
seen+=" $d"
printf '%s\n' "$d"
done
}
turn_index_session_file() {
local sid="${1:-${GROK_SESSION_ID:-unknown}}"
sid="${sid//\//_}"
printf '%s/sessions/%s.jsonl' "$(turn_index_data_dir)" "$sid"
}
# Resolve an existing session index file (read path), or the primary write path.
turn_index_find_session_file() {
local sid="${1:-${GROK_SESSION_ID:-unknown}}"
sid="${sid//\//_}"
local d f
while IFS= read -r d; do
f="${d}/sessions/${sid}.jsonl"
if [[ -f "$f" && -s "$f" ]]; then
printf '%s' "$f"
return 0
fi
done < <(turn_index_data_dirs)
# Default write location
turn_index_session_file "$sid"
}
read_stdin_json() {
cat
}
json_get() {
local field="$1"
local raw="${2:-}"
python3 -c '
import json, sys
field = sys.argv[1]
raw = sys.stdin.read() if not sys.argv[2:] else sys.argv[2]
try:
o = json.loads(raw)
except Exception:
sys.exit(0)
def dig(obj, *keys):
cur = obj
for k in keys:
if not isinstance(cur, dict):
return None
cur = cur.get(k)
return cur
candidates = [
dig(o, field),
dig(o, "toolInput", field),
dig(o, "input", field),
dig(o, "data", field),
]
for c in candidates:
if c is None:
continue
if isinstance(c, str):
print(c)
break
if isinstance(c, (int, float, bool)):
print(c)
break
' "$field" ${raw:+"$raw"}
}
extract_prompt_text() {
python3 -c '
import json, sys, re
raw = sys.stdin.read()
try:
o = json.loads(raw)
except Exception:
print("")
sys.exit(0)
def as_text(v):
if v is None:
return None
if isinstance(v, str):
return v
if isinstance(v, list):
parts = []
for item in v:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
t = item.get("text") or item.get("content")
if isinstance(t, str):
parts.append(t)
return "\n".join(parts) if parts else None
if isinstance(v, dict):
for k in ("text", "content", "prompt", "message"):
if k in v:
t = as_text(v[k])
if t:
return t
return None
keys = (
"prompt", "promptText", "prompt_text", "message", "content",
"userPrompt", "user_prompt", "text",
)
found = None
for k in keys:
if k in o:
found = as_text(o[k])
if found:
break
if not found:
ti = o.get("toolInput") or o.get("input") or {}
if isinstance(ti, dict):
for k in keys:
if k in ti:
found = as_text(ti[k])
if found:
break
if not found:
found = ""
m = re.search(r"<user_query>\s*(.*?)\s*</user_query>", found, re.DOTALL | re.IGNORECASE)
if m:
found = m.group(1).strip()
print(found)
'
}
preview_text() {
local text="$1"
local max="${2:-100}"
python3 -c '
import sys, re
text = sys.argv[1]
max_len = int(sys.argv[2])
text = re.sub(r"\s+", " ", text).strip()
if len(text) > max_len:
text = text[: max_len - 1].rstrip() + "…"
print(text)
' "$text" "$max"
}
ensure_parent() {
mkdir -p "$(dirname "$1")"
}
#!/usr/bin/env bash
# Print the turn index for the current (or given) session.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
SESSION_ID="${1:-${GROK_SESSION_ID:-}}"
MODE="${2:-list}" # list | show | current
TURN_NUM="${3:-}"
resolve_session_id() {
local cwd="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-$(pwd)}}"
python3 -c '
import json, sys, os
from pathlib import Path
cwd = sys.argv[1]
pid = os.getpid()
# Prefer env already handled by caller
p = Path.home() / ".grok" / "active_sessions.json"
if not p.is_file():
sys.exit(0)
try:
data = json.loads(p.read_text())
except Exception:
sys.exit(0)
items = data if isinstance(data, list) else []
matches = []
for it in items:
if not isinstance(it, dict):
continue
if it.get("cwd") == cwd or it.get("workspaceRoot") == cwd or it.get("workspace") == cwd:
sid = it.get("session_id") or it.get("sessionId") or it.get("id")
if sid:
matches.append((it.get("opened_at") or "", sid, it.get("pid")))
if not matches:
# Fallback: most recently opened overall
for it in items:
if not isinstance(it, dict):
continue
sid = it.get("session_id") or it.get("sessionId") or it.get("id")
if sid:
matches.append((it.get("opened_at") or "", sid, it.get("pid")))
if not matches:
sys.exit(0)
# Prefer our parent process tree pid match if possible, else latest opened_at
matches.sort(key=lambda x: x[0])
print(matches[-1][1])
' "$cwd" 2>/dev/null || true
}
if [[ -z "$SESSION_ID" || "$SESSION_ID" == "unknown" ]]; then
SESSION_ID="$(resolve_session_id)"
fi
if [[ -z "$SESSION_ID" ]]; then
# Last-resort: newest session dir for this cwd
CWD="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-$(pwd)}}"
SESSION_ID="$(python3 -c '
import sys, urllib.parse
from pathlib import Path
cwd = sys.argv[1]
root = Path.home() / ".grok" / "sessions" / urllib.parse.quote(cwd, safe="")
if not root.is_dir():
sys.exit(0)
dirs = [p for p in root.iterdir() if p.is_dir()]
dirs.sort(key=lambda p: p.stat().st_mtime)
if dirs:
print(dirs[-1].name)
' "$CWD" 2>/dev/null || true)"
fi
if [[ -z "$SESSION_ID" ]]; then
echo "turn-index: no session id. Pass a session UUID or run inside a Grok session."
echo "Usage: list-turns [session-id] [list|show|current] [turn-number]"
exit 1
fi
FILE="$(turn_index_find_session_file "$SESSION_ID")"
# Opportunistic rebuild if index missing
if [[ ! -f "$FILE" || ! -s "$FILE" ]]; then
GROK_SESSION_ID="$SESSION_ID" "${SCRIPT_DIR}/rebuild-index" 2>/dev/null || true
fi
if [[ ! -f "$FILE" || ! -s "$FILE" ]]; then
echo "turn-index: no turns recorded yet for session ${SESSION_ID}"
echo "Submit a prompt (with the plugin enabled) or resume a session that has history."
exit 0
fi
python3 - "$FILE" "$MODE" "$TURN_NUM" "$SESSION_ID" << 'PY'
import json, sys
path, mode, turn_s, sid = sys.argv[1:5]
rows = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
if not rows:
print(f"turn-index: empty index for {sid}")
sys.exit(0)
if mode == "current":
r = rows[-1]
print(f"Current turn: {r.get('turn')} · {r.get('preview', '')}")
sys.exit(0)
if mode == "show":
try:
n = int(turn_s)
except Exception:
print("Usage: /turn <number>")
sys.exit(1)
match = next((r for r in rows if int(r.get("turn", -1)) == n), None)
if not match:
print(f"No turn {n}. Valid range: 0–{rows[-1].get('turn')}")
sys.exit(1)
print(f"═══ Turn {n} ═══")
if match.get("ts"):
print(f"Time: {match['ts']}")
if match.get("promptIndex") is not None:
print(f"promptIndex: {match['promptIndex']}")
print()
print(match.get("prompt") or match.get("preview") or "(empty)")
sys.exit(0)
# list
print(f"Turn index — session {sid}")
print(f"{len(rows)} user turn(s)\n")
width = len(str(rows[-1].get("turn", len(rows))))
for r in rows:
t = r.get("turn", "?")
prev = r.get("preview") or ""
print(f" {str(t).rjust(width)}. {prev}")
print()
print("Tip: /turn <n> for full prompt text · Shift+Left/Right jumps turns in scrollback")
PY
#!/usr/bin/env bash
MSG="$(cat "${HOME}/.grok/plugin-data/turn-index/CURRENT" 2>/dev/null || echo "Turn")"
if command -v notify-send >/dev/null 2>&1; then
notify-send -a Grok -t 4000 "Grok" "$MSG" 2>/dev/null || true
fi
if [[ -e /dev/tty ]]; then
printf '\033]0;%s — Grok\007' "$MSG" > /dev/tty 2>/dev/null || true
fi
exit 0
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
PAYLOAD="$(cat || true)"
export GROK_PLUGIN_DATA="${HOME}/.grok/plugin-data/turn-index"
mkdir -p "$GROK_PLUGIN_DATA"
DEBUG_LOG="${GROK_PLUGIN_DATA}/debug.log"
CURRENT_FILE="${GROK_PLUGIN_DATA}/CURRENT"
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$DEBUG_LOG" 2>/dev/null || true; }
SESSION_ID="${GROK_SESSION_ID:-unknown}"
if [[ "$SESSION_ID" == "unknown" || -z "$SESSION_ID" ]]; then
SESSION_ID="$(printf '%s' "$PAYLOAD" | python3 -c '
import json,sys
try: o=json.loads(sys.stdin.read() or "{}")
except Exception: o={}
for k in ("sessionId","session_id"):
if o.get(k): print(o[k]); break
' 2>/dev/null || true)"
SESSION_ID="${SESSION_ID:-unknown}"
fi
TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
FILE="$(turn_index_session_file "$SESSION_ID")"
ensure_parent "$FILE"
PROMPT_TEXT="$(printf '%s' "$PAYLOAD" | extract_prompt_text)"
if [[ -z "$PROMPT_TEXT" ]]; then
PROMPT_TEXT="$(printf '%s' "$PAYLOAD" | python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:200] if s else "(empty)")')"
fi
if [[ -f "$FILE" ]]; then
# 0-based: next turn number == current line count
TURN="$(wc -l < "$FILE" | tr -d ' ')"
else
TURN=0
fi
PREVIEW="$(preview_text "$PROMPT_TEXT" 80)"
MSG="Turn ${TURN} · ${PREVIEW}"
python3 -c '
import json, sys
rec = {"turn": int(sys.argv[1]), "ts": sys.argv[2], "sessionId": sys.argv[3],
"preview": sys.argv[4], "prompt": sys.argv[5], "source": "UserPromptSubmit"}
with open(sys.argv[6], "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
' "$TURN" "$TS" "$SESSION_ID" "$PREVIEW" "$PROMPT_TEXT" "$FILE"
printf '%s\n' "$MSG" >"$CURRENT_FILE"
log "UserPromptSubmit $MSG"
# Ask model to print the exact footer (AGENTS.md reinforces this)
python3 -c '
import json, sys
msg = sys.argv[1]
turn = sys.argv[2]
# msg is like "Turn 3 · preview" — strip leading "Turn " if present for clean heading
label = msg if msg.startswith("Turn ") else f"Turn {turn}"
ctx = (
f"MANDATORY UI LABEL: This is user turn {turn}. "
f"End your entire reply with this markdown heading only (no ANSI escapes, not in a code fence):\n"
f"### 🔴 {label}\n"
f"A blank line before the heading is fine. Nothing after it."
)
print(json.dumps({
"additionalContext": ctx,
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": ctx,
},
}, ensure_ascii=False))
' "$MSG" "$TURN"
exit 0
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
export GROK_PLUGIN_DATA="${HOME}/.grok/plugin-data/turn-index"
mkdir -p "$GROK_PLUGIN_DATA"
# Rebuild index if possible
"${SCRIPT_DIR}/rebuild-index" 2>>"${GROK_PLUGIN_DATA}/debug.log" || true
# Superpowers-style context injection (Grok honors this on SessionStart).
python3 - << 'PY'
import json
ctx = (
"You have the turn-index plugin. "
"A Stop hook labels each finished user turn. "
"When the user asks which turn a prompt was on, run: "
"`$HOME/.grok/plugins/turn-index/scripts/list-turns \"$GROK_SESSION_ID\" list` "
"or read `$HOME/.grok/plugin-data/turn-index/CURRENT` for the latest label. "
"Do not invent turn numbers."
)
print(json.dumps({
"additionalContext": ctx,
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": ctx,
},
}))
PY
exit 0
#!/usr/bin/env bash
# Stop: refresh turn index + CURRENT file (for /turns and notifications).
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
PAYLOAD="$(cat || true)"
export GROK_PLUGIN_DATA="${HOME}/.grok/plugin-data/turn-index"
mkdir -p "$GROK_PLUGIN_DATA"
DEBUG_LOG="${GROK_PLUGIN_DATA}/debug.log"
CURRENT_FILE="${GROK_PLUGIN_DATA}/CURRENT"
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$DEBUG_LOG" 2>/dev/null || true; }
SESSION_ID="${GROK_SESSION_ID:-}"
if [[ -z "${SESSION_ID}" || "${SESSION_ID}" == "unknown" ]]; then
SESSION_ID="$(printf '%s' "$PAYLOAD" | python3 -c '
import json,sys
try: o=json.loads(sys.stdin.read() or "{}")
except Exception: o={}
for k in ("sessionId","session_id"):
if o.get(k): print(o[k]); break
' 2>/dev/null || true)"
fi
MSG="Turn ?"
if [[ -n "${SESSION_ID}" && "${SESSION_ID}" != "unknown" ]]; then
export GROK_SESSION_ID="$SESSION_ID"
"${SCRIPT_DIR}/rebuild-index" >>"$DEBUG_LOG" 2>&1 || true
FILE="$(turn_index_find_session_file "$SESSION_ID")"
if [[ -f "$FILE" && -s "$FILE" ]]; then
MSG="$(python3 - "$FILE" << 'PY'
import json, sys
last = None
with open(sys.argv[1], encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line: continue
try: last = json.loads(line)
except json.JSONDecodeError: continue
if not last:
print("Turn ?")
else:
t = last.get("turn", "?")
p = (last.get("preview") or "").strip()
print(f"Turn {t} · {p}" if p else f"Turn {t}")
PY
)"
fi
fi
printf '%s\n' "$MSG" >"$CURRENT_FILE" 2>/dev/null || true
log "Stop $MSG session=${SESSION_ID:-}"
# Desktop notify (best-effort)
command -v notify-send >/dev/null 2>&1 && notify-send -a Grok -t 3000 "Grok" "$MSG" 2>/dev/null || true
exit 0
#!/usr/bin/env bash
# SessionStart: rebuild turn index from session updates.jsonl when available.
set -euo pipefail
export GROK_PLUGIN_DATA="${GROK_PLUGIN_DATA:-${HOME}/.grok/plugin-data/turn-index}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
SESSION_ID="${GROK_SESSION_ID:-}"
if [[ -z "$SESSION_ID" || "$SESSION_ID" == "unknown" ]]; then
exit 0
fi
GROK_HOME="${GROK_HOME:-${HOME}/.grok}"
CWD="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-$(pwd)}}"
FILE="$(turn_index_session_file "$SESSION_ID")"
ensure_parent "$FILE"
python3 - "$GROK_HOME" "$CWD" "$SESSION_ID" "$FILE" << 'PY'
import json, os, sys, urllib.parse, re
from pathlib import Path
grok_home, cwd, session_id, out_file = sys.argv[1:5]
sessions_root = Path(grok_home) / "sessions"
# Prefer encoded-cwd layout: sessions/<encoded-cwd>/<session-id>/updates.jsonl
candidates = []
enc = urllib.parse.quote(cwd, safe="")
candidates.append(sessions_root / enc / session_id / "updates.jsonl")
# Fallback: search by session id
if sessions_root.is_dir():
for p in sessions_root.rglob(session_id):
if p.is_dir():
u = p / "updates.jsonl"
if u.is_file():
candidates.append(u)
updates = None
for c in candidates:
if c.is_file():
updates = c
break
if updates is None:
# No on-disk history yet (brand-new session) — leave index empty/untouched.
sys.exit(0)
# Collect user prompts by promptIndex
by_index = {} # int -> list of text chunks
order = []
with updates.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
params = ev.get("params") or {}
update = params.get("update") or {}
if update.get("sessionUpdate") != "user_message_chunk":
continue
content = update.get("content") or {}
text = content.get("text") if isinstance(content, dict) else None
if not isinstance(text, str) or not text:
continue
meta = update.get("_meta") or {}
idx = meta.get("promptIndex")
if idx is None:
idx = len(order)
try:
idx = int(idx)
except (TypeError, ValueError):
continue
if idx not in by_index:
by_index[idx] = []
order.append(idx)
by_index[idx].append(text)
if not by_index:
sys.exit(0)
def clean_prompt(text: str) -> str:
m = re.search(r"<user_query>\s*(.*?)\s*</user_query>", text, re.DOTALL | re.IGNORECASE)
if m:
return m.group(1).strip()
# Drop huge system-reminder dumps for preview cleanliness
if text.lstrip().startswith("<system-reminder>") or text.lstrip().startswith("<user_info>"):
# Keep a short marker if no user_query found
return text.strip()[:200]
return text.strip()
def preview(text: str, n: int = 120) -> str:
t = re.sub(r"\s+", " ", text).strip()
if len(t) > n:
return t[: n - 1].rstrip() + "…"
return t
# Sort by promptIndex ascending
sorted_idxs = sorted(by_index.keys())
records = []
for i, idx in enumerate(sorted_idxs, start=0):
full = "".join(by_index[idx])
full = clean_prompt(full)
records.append({
"turn": i,
"promptIndex": idx,
"ts": None,
"sessionId": session_id,
"preview": preview(full),
"prompt": full,
"source": "updates.jsonl",
})
# Always rewrite from authoritative session log on start (handles resume/rewind).
tmp = out_file + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for rec in records:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
os.replace(tmp, out_file)
print(f"turn-index: rebuilt {len(records)} turns for session {session_id[:8]}…", file=sys.stderr)
PY
exit 0
name turns
description List or look up which user prompt was on which turn in the current Grok session. Use when the user asks about turn numbers, "which turn", "what did I ask on turn N", or runs /turns or /turn.
disable-model-invocation false
user-invocable true
argument-hint [turn-number]

Turn index

Map user prompts ↔ turn numbers for long Grok sessions.

Turns are 0-based (first user prompt is turn 0).

Steps

  1. Run $HOME/.grok/plugins/turn-index/scripts/list-turns (or $GROK_PLUGIN_ROOT/scripts/list-turns).
  2. Return script output without renumbering.

Auto labels

Every reply should end with this markdown heading (no ANSI):

🔴 Turn N

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment