User pushback: "Агент должен делать осмысленные выводы! С утра должен
быть отчет и пусть он приходит куда-то! На телеграмм, например, лучше
сразу после фазы сна, бот есть"
Wires the @KeiSeiBot Telegram bot as the delivery channel for nightly
Phase B reports, with a Claude Sonnet 4.6 reasoning step in front to
distil the multi-section markdown into a single actionable brief.
NEW — `hooks/sleep-report-tg.sh` (130 LOC POSIX bash)
Pipeline:
1. Source ~/.claude/secrets/.env (umbrella SSoT — RULE 0.8)
2. POST report markdown to Claude API messages endpoint with a
system prompt mandating: TL;DR + numbers + 3-5 actionable
findings + rule-candidates if any cross-session pattern ≥3×.
Sonnet 4.6, max_tokens=1500, 120s timeout.
3. Send distilled summary via Telegram sendMessage to whitelisted
chat_id (defaults to TELEGRAM_ALLOWED_CHAT_ID env, falls back
to 86059912).
4. Cap message at 3900 chars (TG limit 4096).
5. Fallback if Markdown parse_mode fails (orphan * / [ in body) →
retry without parse_mode so the user still sees the report.
6. Defensive on every step: missing API key → send raw excerpt;
missing curl/jq → log + exit 0; HTTP failure → log + exit 0.
7. Bypass: SLEEP_REPORT_TG_BYPASS=1.
WIRE — `hooks/phase-b-rem.sh`
Step 7 (new) calls sleep-report-tg.sh after the existing commit/push
step. Failure of TG delivery never affects Phase B's exit code —
the local report + memory-repo push remain the source-of-truth;
TG is convenience.
CONFIG (already done outside this commit, documented for completeness)
- ~/.claude/secrets/.env now has TELEGRAM_BOT_TOKEN +
TELEGRAM_ALLOWED_CHAT_ID (single-user whitelist 86059912).
- ~/.claude/tg-webhook.py whitelist locked to {86059912}; group
chat (-1003758632751) and partner (10954083) removed per
user request "сделай боту только один вайт адрес". Blocked
senders land in /var/log/tg-webhook/blocked.jsonl, no auto-reply.
- ~/.claude/tg-contacts.json shrunk from 3 contacts to 1.
Smoke verified: today's sleep-2026-05-02.md → cloud agent emitted
TL;DR ("Opus burned $1239 across 117 runs with 100% unknown outcomes")
+ 5 findings + 3 rule-candidates → delivered to chat_id 86059912 as
msg_id 1129 (HTTP 200). Cost: 3955 in + 897 out tokens on Sonnet
≈ $0.025/run. At 1 run/night that is ~$0.75/month for full reasoning
on every nightly report.
What this does NOT yet do:
- No retry on Telegram rate-limit (429). Single nightly call
is well below the 30/sec limit, but if the system ever bursts
multiple reports it would lose them.
- No multi-day digest mode (each run is independent; future:
weekly Sunday recap aggregating 7 reports).
- Cloud agent prompt is hard-coded inline; future: extract to
a path-atom-style block (post-2026-05-02 substrate work).
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: NOT-RUN (pure shell)
behaviour-verified: yes
follow-up-required:
- Phase B prompt template extracted to atom (low priority)
- Weekly recap mode (Sunday)
- 429 rate-limit retry (defensive)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
8.9 KiB
Bash
Executable file
243 lines
8.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# phase-b-rem.sh — RULE 0.15 Phase B (REM consolidation) — unified runner
|
|
#
|
|
# Triggered by ONE of (user picks during /sleep-setup):
|
|
# - .forgejo/workflows/phase-b-rem.yml → VPS forgejo-runner (Tailscale, always-on)
|
|
# - ~/Library/LaunchAgents/io.keisei.phase-b-rem.plist → local Mac launchd
|
|
# - .github/workflows/phase-b-rem.yml → public-safe GitHub Actions (other users)
|
|
#
|
|
# What it does:
|
|
# 1. cd to sync-repo, git pull --rebase
|
|
# 2. Find new trace files since reports/last-run.txt
|
|
# 3. Run kei-memory analyze + patterns (cross-session)
|
|
# 4. Write reports/sleep-YYYY-MM-DD.md
|
|
# 5. Update reports/last-run.txt
|
|
# 6. Commit + push
|
|
#
|
|
# Guards:
|
|
# - If no new traces → exit clean, no commit
|
|
# - If trace contains [PRIVATE] marker → skip that trace
|
|
# - 60-min hard wall-clock budget
|
|
# - Reports go to HUMAN review next morning, no auto-inject
|
|
|
|
set -euo pipefail
|
|
IFS=$'\n\t'
|
|
|
|
# Source secrets if present (REPO_PATH, etc.)
|
|
[ -f ~/.claude/secrets/.env ] && set -a && source ~/.claude/secrets/.env && set +a
|
|
|
|
REPO_PATH="${KEI_MEMORY_REPO_PATH:-$HOME/.claude/memory/sync-repo}"
|
|
# kei-memory binary resolution order (first hit wins):
|
|
# 1. Explicit env override KEI_MEMORY_BIN=/some/path
|
|
# 2. PATH lookup ~/.cargo/bin/kei-memory typically
|
|
# 3. KeiSeiKit-public local-build target dir
|
|
# 4. Legacy KeiSeiKit (pre-public-rebrand) target dir
|
|
if [ -z "${KEI_MEMORY_BIN:-}" ]; then
|
|
if command -v kei-memory >/dev/null 2>&1; then
|
|
KEI_MEMORY_BIN="$(command -v kei-memory)"
|
|
elif [ -x "$HOME/Projects/KeiSeiKit-public/_primitives/_rust/target/release/kei-memory" ]; then
|
|
KEI_MEMORY_BIN="$HOME/Projects/KeiSeiKit-public/_primitives/_rust/target/release/kei-memory"
|
|
else
|
|
KEI_MEMORY_BIN="$HOME/Projects/KeiSeiKit/_primitives/_rust/target/release/kei-memory"
|
|
fi
|
|
fi
|
|
TODAY=$(date +%Y-%m-%d)
|
|
NOW_TS=$(date +%s)
|
|
WALL_BUDGET_S=3600
|
|
START_TS=$NOW_TS
|
|
|
|
log() { printf '[phase-b %s] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
|
|
|
[ -d "$REPO_PATH" ] || { log "ERROR: $REPO_PATH not found"; exit 1; }
|
|
cd "$REPO_PATH"
|
|
|
|
# Step 1 — pull latest
|
|
log "Step 1/6: git pull --rebase"
|
|
git pull --rebase 2>&1 | tail -3 || { log "WARN: pull failed (offline?), continuing with local state"; }
|
|
|
|
# Step 2 — find new traces
|
|
log "Step 2/6: scan traces/ for new files since reports/last-run.txt"
|
|
LAST_RUN_FILE="reports/last-run.txt"
|
|
if [ -f "$LAST_RUN_FILE" ]; then
|
|
LAST_TS=$(head -1 "$LAST_RUN_FILE" 2>/dev/null || echo 0)
|
|
else
|
|
LAST_TS=0
|
|
fi
|
|
|
|
NEW_TRACES=()
|
|
if [ -d traces ]; then
|
|
while IFS= read -r f; do
|
|
[ -f "$f" ] || continue
|
|
mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0)
|
|
if [ "$mtime" -gt "$LAST_TS" ]; then
|
|
NEW_TRACES+=("$f")
|
|
fi
|
|
done < <(find traces -name '*.jsonl' -type f 2>/dev/null)
|
|
fi
|
|
|
|
if [ ${#NEW_TRACES[@]} -eq 0 ]; then
|
|
log "no new traces since $(date -r $LAST_TS '+%Y-%m-%d %H:%M' 2>/dev/null || echo never) — clean exit"
|
|
exit 0
|
|
fi
|
|
log "found ${#NEW_TRACES[@]} new traces"
|
|
|
|
# Step 3 — kei-memory analyze + patterns
|
|
log "Step 3/6: kei-memory analyze + patterns"
|
|
ANALYZE_OUT="/tmp/phase-b-analyze-$TODAY.txt"
|
|
PATTERNS_OUT="/tmp/phase-b-patterns-$TODAY.txt"
|
|
|
|
if [ -x "$KEI_MEMORY_BIN" ]; then
|
|
"$KEI_MEMORY_BIN" analyze --last 30 > "$ANALYZE_OUT" 2>&1 || log "WARN: analyze failed"
|
|
"$KEI_MEMORY_BIN" patterns --cross-session > "$PATTERNS_OUT" 2>&1 || log "WARN: patterns failed"
|
|
else
|
|
log "WARN: kei-memory binary not found at $KEI_MEMORY_BIN — skipping analysis (will only count traces)"
|
|
echo "kei-memory not available on this runner" > "$ANALYZE_OUT"
|
|
echo "kei-memory not available on this runner" > "$PATTERNS_OUT"
|
|
fi
|
|
|
|
# Budget check
|
|
ELAPSED=$(( $(date +%s) - START_TS ))
|
|
if [ "$ELAPSED" -gt "$WALL_BUDGET_S" ]; then
|
|
log "ERROR: wall budget exceeded ($ELAPSED s > $WALL_BUDGET_S s)"
|
|
exit 2
|
|
fi
|
|
|
|
# Step 4 — write report
|
|
log "Step 4/6: write reports/sleep-$TODAY.md"
|
|
mkdir -p reports
|
|
REPORT="reports/sleep-$TODAY.md"
|
|
{
|
|
echo "# REM consolidation — $TODAY"
|
|
echo
|
|
echo "**Sessions consolidated:** ${#NEW_TRACES[@]}"
|
|
echo "**Wall time:** $((($(date +%s) - START_TS))) s"
|
|
echo "**Runner:** $(hostname) ($(uname -s) $(uname -m))"
|
|
echo
|
|
echo "## New traces"
|
|
for f in "${NEW_TRACES[@]}"; do
|
|
sz=$(stat -f %z "$f" 2>/dev/null || stat -c %s "$f" 2>/dev/null || echo 0)
|
|
echo "- \`$f\` ($sz bytes)"
|
|
done
|
|
echo
|
|
echo "## Cross-session analysis"
|
|
echo
|
|
echo "### Analyze (last 30 sessions)"
|
|
echo '```'
|
|
head -100 "$ANALYZE_OUT"
|
|
echo '```'
|
|
echo
|
|
echo "### Patterns (cross-session)"
|
|
echo '```'
|
|
head -100 "$PATTERNS_OUT"
|
|
echo '```'
|
|
echo
|
|
# ----- Per-axis observability digests (added 2026-05-02) -----
|
|
# Cloud agents and morning human review get an actionable rollup of the
|
|
# tracking journals without having to parse multi-thousand-line JSONL.
|
|
if [ -d "ledger" ] || [ -d "time-metrics" ]; then
|
|
echo "## Tracking observability (last 7 days)"
|
|
echo
|
|
fi
|
|
|
|
if [ -f "ledger/agents.jsonl" ] && [ -s "ledger/agents.jsonl" ] \
|
|
&& command -v jq >/dev/null 2>&1; then
|
|
echo "### Agent outcomes — ledger/agents.jsonl"
|
|
echo '```'
|
|
SEVEN_DAYS_AGO=$(( $(date +%s) - 7*86400 ))
|
|
jq -s --argjson cutoff "$SEVEN_DAYS_AGO" '
|
|
[.[] | select(.started_ts >= $cutoff)]
|
|
| group_by(.model) | map({
|
|
model: .[0].model,
|
|
n: length,
|
|
functional: ([.[] | select(.outcome=="functional")] | length),
|
|
partial: ([.[] | select(.outcome=="partial")] | length),
|
|
scaffolding: ([.[] | select(.outcome=="scaffolding")] | length),
|
|
fail: ([.[] | select(.outcome=="fail")] | length),
|
|
unknown: ([.[] | select(.outcome==null or .outcome=="")] | length),
|
|
total_cost_usd: (([.[] | .cost_micro_cents // 0] | add) / 100000000)
|
|
})' ledger/agents.jsonl 2>/dev/null | head -100
|
|
echo '```'
|
|
echo
|
|
fi
|
|
|
|
if [ -f "ledger/skill_invocations.jsonl" ] && [ -s "ledger/skill_invocations.jsonl" ] \
|
|
&& command -v jq >/dev/null 2>&1; then
|
|
echo "### Skill success rates — ledger/skill_invocations.jsonl"
|
|
echo '```'
|
|
SEVEN_DAYS_AGO=$(( $(date +%s) - 7*86400 ))
|
|
jq -s --argjson cutoff "$SEVEN_DAYS_AGO" '
|
|
[.[] | select(.ts >= $cutoff)]
|
|
| group_by(.skill_name) | map({
|
|
skill: .[0].skill_name,
|
|
n: length,
|
|
successes: ([.[] | select(.success==1)] | length),
|
|
rate_pct: ((([.[] | select(.success==1)] | length) * 100) / length)
|
|
}) | sort_by(.n) | reverse' ledger/skill_invocations.jsonl 2>/dev/null | head -50
|
|
echo '```'
|
|
echo
|
|
fi
|
|
|
|
if [ -f "time-metrics/numeric-claims.jsonl" ] \
|
|
&& [ -s "time-metrics/numeric-claims.jsonl" ] \
|
|
&& command -v jq >/dev/null 2>&1; then
|
|
echo "### Numeric-claims tier breakdown — time-metrics/numeric-claims.jsonl"
|
|
echo '```'
|
|
jq -s 'group_by(.evidence_tier) | map({tier: .[0].evidence_tier, n: length})' \
|
|
time-metrics/numeric-claims.jsonl 2>/dev/null
|
|
echo '```'
|
|
echo "_RULE 0.18 health: high ESTIMATE-HTC ratio = orchestrator under-calibrated. Cloud agent should propose converting frequent ESTIMATE-HTC categories into FROM-JOURNAL via measured runs._"
|
|
echo
|
|
fi
|
|
|
|
if [ -f "time-metrics/agent-toolstats.jsonl" ] \
|
|
&& [ -s "time-metrics/agent-toolstats.jsonl" ] \
|
|
&& command -v jq >/dev/null 2>&1; then
|
|
echo "### Agent tool-call patterns — time-metrics/agent-toolstats.jsonl"
|
|
echo '```'
|
|
jq -s '
|
|
[.[] | select(.tool_stats != null)] as $rows
|
|
| ($rows | length) as $n
|
|
| {
|
|
n_with_stats: $n,
|
|
mean_tool_uses: (if $n == 0 then 0
|
|
else (($rows | map(.tool_use_count // 0) | add) / $n) end),
|
|
mean_duration_ms: (if $n == 0 then 0
|
|
else (($rows | map(.duration_ms // 0) | add) / $n) end),
|
|
tool_distribution: (
|
|
[$rows[] | .tool_stats // {} | to_entries[]]
|
|
| group_by(.key)
|
|
| map({tool: .[0].key, total_calls: ([.[] | .value] | add)})
|
|
| sort_by(.total_calls) | reverse
|
|
)
|
|
}' time-metrics/agent-toolstats.jsonl 2>/dev/null
|
|
echo '```'
|
|
echo
|
|
fi
|
|
|
|
echo "## For human review"
|
|
echo
|
|
echo "- Anything in patterns above appearing >=3 times across sessions deserves a rule + hook"
|
|
echo "- See \`/escalate-recurrence\` skill for codification flow"
|
|
echo
|
|
echo "_Generated by RULE 0.15 Phase B nightly. Not auto-injected into next session._"
|
|
} > "$REPORT"
|
|
|
|
# Step 5 — update last-run timestamp
|
|
echo "$NOW_TS" > "$LAST_RUN_FILE"
|
|
|
|
# Step 6 — commit + push
|
|
log "Step 6/6: commit + push"
|
|
git add reports/ "$LAST_RUN_FILE"
|
|
git commit -m "REM: consolidation $TODAY (${#NEW_TRACES[@]} new traces)" 2>&1 | tail -3 || { log "nothing to commit"; exit 0; }
|
|
git push 2>&1 | tail -3 || { log "WARN: push failed"; exit 1; }
|
|
|
|
log "DONE — $REPORT pushed"
|
|
|
|
# Step 7 — Telegram delivery (defensive: never fails Phase B)
|
|
log "Step 7/7: tg delivery"
|
|
TG_HOOK="$HOME/.claude/hooks/sleep-report-tg.sh"
|
|
if [ -x "$TG_HOOK" ]; then
|
|
"$TG_HOOK" "$REPORT" "$TODAY" 2>&1 | tail -3 || true
|
|
else
|
|
log "WARN: $TG_HOOK not present, skipping tg"
|
|
fi
|