Files
ai-agent/scripts/conv-scaffold.sh
Gabriel Vidal 8dccce2334 feat(ctas): replace the conversation-hook scheduler with end-of-conversation CTAs
A hook resumed every finished run on a timer — unasked-for, and expensive: a
`--resume` never hits the finished run's prompt cache, so each firing re-created
the whole transcript as fresh input tokens. The prompts stay, the scheduler goes.

A CTA is a prompt file plus a button under the last message of a finished
conversation: click loads its prompt into the resume box (editable before it is
sent), long-press fires it at the session in the background. The same prompts are
selectable as `cta:<id>` tags in every composer.

The seeded `complete` CTA also does the post-processing the hook never could
afford: `conv-scaffold` reduces the conversation to markdown — metadata, its
prompts, merged bash/tool/subagent summaries, one squashed diff across every
commit it produced — and one subagent fills in the Summary/Analysis and publishes
it onto `meta.summary`, rendered under the thread.

- backend: ctas.py (store + seeded Complete), scaffold.py (+ /scaffold, PUT
  /summary), /api/ctas CRUD/run/prompt/reorder; hooks.py and its scheduler,
  pending queue and routes deleted (read-only /api/claude-hooks stays)
- frontend: ConversationCtas section, Settings → CTAs, cta tags, CtaBadge
- scripts/conv-scaffold.sh: scaffold + publish CLI

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:46:27 +02:00

136 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# conv-scaffold — scaffold a conversation's completion summary, and publish it.
#
# (In the homelab this is on PATH as `conv-scaffold`, via the alias shim at
# services/ai-agent/cli/conv-scaffold.sh which execs this script.)
#
# The CLI half of the `Complete` CTA (see backend/scaffold.py). The backend
# already parses transcripts and aggregates a conversation's commits, so the
# expensive, mechanical part of "summarise what happened here" — metadata, the
# prompts, the merged bash/tool/subagent summaries, one squashed diff across
# every commit — is generated instead of re-read out of the transcript by a
# model. What comes back is markdown with two sections left blank.
#
# conv-scaffold # scaffold THIS conversation → prints the path
# conv-scaffold --session <sid> # …a specific session
# conv-scaffold --stdout # write to stdout instead of a file
# conv-scaffold publish <file> # publish a filled-in scaffold as the summary
# conv-scaffold show # print the currently published summary
#
# Scaffolds are written to $TMPDIR/conv-scaffold-<sid>.md by default; pass
# `-o <path>` to choose. Publishing stores the markdown on the conversation's
# metadata sidecar, which is what the viewer renders under the thread — an
# untouched scaffold is refused (HTTP 422), because a report of nothing is worse
# than no report.
#
# Session resolution, in order: --session, $CLAUDE_SESSION_ID, then the homelab
# conv-meta helper (PID-ancestry match) when it is on this machine.
set -uo pipefail
AI_AGENT_URL="${AI_AGENT_URL:-http://127.0.0.1:8096}"
die() { echo "conv-scaffold: $*" >&2; exit 1; }
resolve_session() {
[[ -n "${SESSION:-}" ]] && { echo "$SESSION"; return; }
[[ -n "${CLAUDE_SESSION_ID:-}" ]] && { echo "$CLAUDE_SESSION_ID"; return; }
local helper="$HOME/homelab/.claude/skills/conv-meta/scripts/resolve-session.sh"
[[ -x "$helper" ]] && "$helper" 2>/dev/null && return
echo ""
}
# sessionId → the transcript id the API is keyed by (`<slug>/<sid>.jsonl`).
conv_id_for() {
local sid="$1"
curl -s -m 20 "$AI_AGENT_URL/api/conversations" \
| python3 -c '
import json, sys
sid = sys.argv[1]
try:
data = json.load(sys.stdin)
except ValueError:
sys.exit(1)
for c in data.get("conversations") or []:
if c.get("sessionId") == sid:
print(c.get("id") or "")
break
' "$sid"
}
cmd_scaffold() {
local sid out
sid="$(resolve_session)"
[[ -n "$sid" ]] || die "could not work out which session this is — pass --session <id>"
local cid; cid="$(conv_id_for "$sid")"
[[ -n "$cid" ]] || die "no transcript found for session $sid (has it synced yet?)"
local body code tmp
tmp="$(mktemp)"
code="$(curl -s -m 120 -o "$tmp" -w '%{http_code}' \
"$AI_AGENT_URL/api/conversations/$cid/scaffold")"
if [[ "$code" != "200" ]]; then
body="$(cat "$tmp")"; rm -f "$tmp"
die "scaffold failed (HTTP $code): ${body:0:300}"
fi
if [[ -n "${TO_STDOUT:-}" ]]; then
cat "$tmp"; rm -f "$tmp"; return
fi
out="${OUTFILE:-${TMPDIR:-/tmp}/conv-scaffold-$sid.md}"
mv "$tmp" "$out"
echo "$out"
echo "next: fill in the ## Summary and ## Analysis sections, then run" >&2
echo " conv-scaffold publish $out" >&2
}
cmd_publish() {
local file="${1:-}"
[[ -n "$file" && -f "$file" ]] || die "usage: conv-scaffold publish <file>"
local sid; sid="$(resolve_session)"
[[ -n "$sid" ]] || die "could not work out which session this is — pass --session <id>"
local cid; cid="$(conv_id_for "$sid")"
[[ -n "$cid" ]] || die "no transcript found for session $sid"
local payload tmp code
payload="$(python3 -c 'import json,sys; print(json.dumps({"markdown": open(sys.argv[1], encoding="utf-8").read()}))' "$file")"
tmp="$(mktemp)"
code="$(curl -s -m 30 -o "$tmp" -w '%{http_code}' -X PUT \
-H 'Content-Type: application/json' -d "$payload" \
"$AI_AGENT_URL/api/conversations/$cid/summary")"
if [[ "$code" != "200" ]]; then
local body; body="$(cat "$tmp")"; rm -f "$tmp"
die "publish failed (HTTP $code): ${body:0:300}"
fi
rm -f "$tmp"
echo "published — the summary now shows under the conversation"
}
cmd_show() {
local sid; sid="$(resolve_session)"
[[ -n "$sid" ]] || die "could not work out which session this is — pass --session <id>"
curl -s -m 20 "$AI_AGENT_URL/api/conversation-meta" \
| python3 -c '
import json, sys
sid = sys.argv[1]
meta = (json.load(sys.stdin) or {}).get("meta") or {}
entry = (meta.get(sid) or {}).get("summary") or {}
print(entry.get("markdown") or "(no summary published for this conversation)")
' "$sid"
}
ACTION=scaffold
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
publish|show) ACTION="$1"; shift ;;
--session) SESSION="${2:-}"; shift 2 ;;
-o|--out) OUTFILE="${2:-}"; shift 2 ;;
--stdout) TO_STDOUT=1; shift ;;
-h|--help) sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) ARGS+=("$1"); shift ;;
esac
done
case "$ACTION" in
scaffold) cmd_scaffold ;;
publish) cmd_publish "${ARGS[0]:-}" ;;
show) cmd_show ;;
esac