Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions scripts/lib/error-handling.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/bash
# CodeSensei — Shared Error Handling Library
# Source this file in hook scripts for consistent error logging and JSON safety.
#
# Usage:
# source "$(dirname "$0")/lib/error-handling.sh"

LOG_FILE="${HOME}/.code-sensei/error.log"
MAX_LOG_LINES=1000

# Log an error with timestamp and script name.
# Usage: log_error "script-name" "message"
log_error() {
local script_name="${1:-unknown}"
local message="$2"
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')
mkdir -p "$(dirname "$LOG_FILE")"
printf '[%s] [%s] %s\n' "$timestamp" "$script_name" "$message" >> "$LOG_FILE"
# Cap log file to MAX_LOG_LINES
if [ -f "$LOG_FILE" ]; then
local line_count
line_count=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)
if [ "$line_count" -gt "$MAX_LOG_LINES" ]; then
tail -n "$MAX_LOG_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
fi
fi
}

# Safely escape a string for JSON interpolation.
# Returns a JSON-encoded string including surrounding double quotes.
# Usage: escaped=$(json_escape "$var")
json_escape() {
local str="$1"
if command -v jq &>/dev/null; then
printf '%s' "$str" | jq -Rs '.'
else
# Basic fallback: escape backslashes, double quotes, and common control chars
printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')"
fi
}

# Check that jq is installed. Logs a one-time warning per session if missing.
# Usage: check_jq "script-name" || exit 0
check_jq() {
if ! command -v jq &>/dev/null; then
local warn_file="${HOME}/.code-sensei/.jq-warned"
if [ ! -f "$warn_file" ]; then
log_error "${1:-unknown}" "jq not installed — CodeSensei features limited. Install with: brew install jq"
touch "$warn_file"
fi
return 1
fi
return 0
}
173 changes: 140 additions & 33 deletions scripts/quiz-selector.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,34 @@
# "quiz_format": "multiple_choice" | "free_response" | "code_prediction"
# }

SCRIPT_NAME="quiz-selector"
PROFILE_DIR="$HOME/.code-sensei"
PROFILE_FILE="$PROFILE_DIR/profile.json"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}"
QUIZ_BANK="$PLUGIN_ROOT/data/quiz-bank.json"

# Load shared error handling
LIB_DIR="$(dirname "$0")/lib"
if [ -f "$LIB_DIR/error-handling.sh" ]; then
source "$LIB_DIR/error-handling.sh"
else
LOG_FILE="${PROFILE_DIR}/error.log"
log_error() { printf '[%s] [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')" "${1:-unknown}" "$2" >> "$LOG_FILE" 2>/dev/null; }
json_escape() {
local str="$1"
if command -v jq &>/dev/null; then
printf '%s' "$str" | jq -Rs '.'
else
printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')"
fi
}
check_jq() { command -v jq &>/dev/null; }
fi

# Default output if we can't determine anything
DEFAULT_OUTPUT='{"mode":"dynamic","concept":null,"reason":"No profile data available","static_question":null,"belt":"white","quiz_format":"multiple_choice"}'

if ! command -v jq &> /dev/null; then
if ! check_jq "$SCRIPT_NAME"; then
echo "$DEFAULT_OUTPUT"
exit 0
fi
Expand All @@ -33,12 +52,42 @@ if [ ! -f "$PROFILE_FILE" ]; then
fi

# Read profile data
BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE")
QUIZ_HISTORY=$(jq -c '.quiz_history // []' "$PROFILE_FILE")
CONCEPTS_SEEN=$(jq -c '.concepts_seen // []' "$PROFILE_FILE")
SESSION_CONCEPTS=$(jq -c '.session_concepts // []' "$PROFILE_FILE")
TOTAL_QUIZZES=$(jq -r '.quizzes.total // 0' "$PROFILE_FILE")
CORRECT_QUIZZES=$(jq -r '.quizzes.correct // 0' "$PROFILE_FILE")
BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading belt: $BELT"
echo "$DEFAULT_OUTPUT"
exit 0
fi

QUIZ_HISTORY=$(jq -c '.quiz_history // []' "$PROFILE_FILE" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading quiz_history: $QUIZ_HISTORY"
QUIZ_HISTORY="[]"
fi

CONCEPTS_SEEN=$(jq -c '.concepts_seen // []' "$PROFILE_FILE" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading concepts_seen: $CONCEPTS_SEEN"
CONCEPTS_SEEN="[]"
fi

SESSION_CONCEPTS=$(jq -c '.session_concepts // []' "$PROFILE_FILE" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading session_concepts: $SESSION_CONCEPTS"
SESSION_CONCEPTS="[]"
fi

TOTAL_QUIZZES=$(jq -r '.quizzes.total // 0' "$PROFILE_FILE" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading quizzes.total: $TOTAL_QUIZZES"
TOTAL_QUIZZES=0
fi

CORRECT_QUIZZES=$(jq -r '.quizzes.correct // 0' "$PROFILE_FILE" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading quizzes.correct: $CORRECT_QUIZZES"
CORRECT_QUIZZES=0
fi

TODAY=$(date -u +%Y-%m-%d)
NOW_EPOCH=$(date +%s)
Expand All @@ -65,7 +114,7 @@ SPACED_REP_REASON=""

if [ "$QUIZ_HISTORY" != "[]" ]; then
# Get concepts that were answered incorrectly, with their last wrong date and wrong count
WRONG_CONCEPTS=$(echo "$QUIZ_HISTORY" | jq -c '
WRONG_CONCEPTS=$(printf '%s' "$QUIZ_HISTORY" | jq -c '
[.[] | select(.result == "incorrect")] |
group_by(.concept) |
map({
Expand All @@ -74,20 +123,39 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then
last_wrong: (sort_by(.timestamp) | last | .timestamp),
total_attempts: 0
})
')
' 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed computing wrong concepts: $WRONG_CONCEPTS"
WRONG_CONCEPTS="[]"
fi

# For each wrong concept, check if it's due for review
for ROW in $(echo "$WRONG_CONCEPTS" | jq -c '.[]'); do
CONCEPT=$(echo "$ROW" | jq -r '.concept')
WRONG_COUNT=$(echo "$ROW" | jq -r '.wrong_count')
LAST_WRONG=$(echo "$ROW" | jq -r '.last_wrong')
for ROW in $(printf '%s' "$WRONG_CONCEPTS" | jq -c '.[]' 2>/dev/null); do
CONCEPT=$(printf '%s' "$ROW" | jq -r '.concept' 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading concept from wrong row: $CONCEPT"
continue
fi

# Calculate days since last wrong answer
LAST_WRONG_DATE=$(echo "$LAST_WRONG" | cut -d'T' -f1)
WRONG_COUNT=$(printf '%s' "$ROW" | jq -r '.wrong_count' 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading wrong_count: $WRONG_COUNT"
continue
fi

LAST_WRONG=$(printf '%s' "$ROW" | jq -r '.last_wrong' 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading last_wrong: $LAST_WRONG"
continue
fi

# Calculate days since last wrong answer (handles both GNU and BSD date)
LAST_WRONG_DATE=$(printf '%s' "$LAST_WRONG" | cut -d'T' -f1)
LAST_EPOCH=$(date -j -f "%Y-%m-%d" "$LAST_WRONG_DATE" +%s 2>/dev/null || date -d "$LAST_WRONG_DATE" +%s 2>/dev/null)
if [ -n "$LAST_EPOCH" ]; then
DAYS_SINCE=$(( (NOW_EPOCH - LAST_EPOCH) / 86400 ))
else
log_error "$SCRIPT_NAME" "Could not parse date '$LAST_WRONG_DATE' for spaced repetition; defaulting days_since=999"
DAYS_SINCE=999
fi

Expand All @@ -100,9 +168,13 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then
fi

# Check if enough time has passed and concept hasn't been mastered since
CORRECT_SINCE=$(echo "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" --arg lw "$LAST_WRONG" '
CORRECT_SINCE=$(printf '%s' "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" --arg lw "$LAST_WRONG" '
[.[] | select(.concept == $c and .result == "correct" and .timestamp > $lw)] | length
')
' 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed computing correct_since for $CONCEPT: $CORRECT_SINCE"
CORRECT_SINCE=0
fi

if [ "$DAYS_SINCE" -ge "$REVIEW_INTERVAL" ] && [ "$CORRECT_SINCE" -lt 3 ]; then
SPACED_REP_CONCEPT="$CONCEPT"
Expand All @@ -118,23 +190,36 @@ if [ -n "$SPACED_REP_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then
.quizzes[$concept] // [] |
map(select(.belt == $belt or .belt == "white")) |
first // null
' "$QUIZ_BANK")
' "$QUIZ_BANK" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading static question for $SPACED_REP_CONCEPT: $STATIC_Q"
STATIC_Q="null"
fi

ESCAPED_CONCEPT=$(json_escape "$SPACED_REP_CONCEPT")
ESCAPED_REASON=$(json_escape "$SPACED_REP_REASON")
ESCAPED_BELT=$(json_escape "$BELT")

if [ "$STATIC_Q" != "null" ] && [ -n "$STATIC_Q" ]; then
echo "{\"mode\":\"spaced_repetition\",\"concept\":\"$SPACED_REP_CONCEPT\",\"reason\":\"$SPACED_REP_REASON\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
exit 0
printf '{"mode":"spaced_repetition","concept":%s,"reason":%s,"static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \
"$ESCAPED_CONCEPT" "$ESCAPED_REASON" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT"
else
echo "{\"mode\":\"spaced_repetition\",\"concept\":\"$SPACED_REP_CONCEPT\",\"reason\":\"$SPACED_REP_REASON\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
exit 0
printf '{"mode":"spaced_repetition","concept":%s,"reason":%s,"static_question":null,"belt":%s,"quiz_format":"%s"}\n' \
"$ESCAPED_CONCEPT" "$ESCAPED_REASON" "$ESCAPED_BELT" "$QUIZ_FORMAT"
fi
exit 0
fi

# ─── PRIORITY 2: Unquizzed session concepts ───
# Concepts from this session that haven't been quizzed yet
UNQUIZZED_CONCEPT=""
if [ "$SESSION_CONCEPTS" != "[]" ]; then
for CONCEPT in $(echo "$SESSION_CONCEPTS" | jq -r '.[]'); do
BEEN_QUIZZED=$(echo "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" '[.[] | select(.concept == $c)] | length')
for CONCEPT in $(printf '%s' "$SESSION_CONCEPTS" | jq -r '.[]' 2>/dev/null); do
BEEN_QUIZZED=$(printf '%s' "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" '[.[] | select(.concept == $c)] | length' 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed checking quiz history for $CONCEPT: $BEEN_QUIZZED"
continue
fi
if [ "$BEEN_QUIZZED" -eq 0 ]; then
UNQUIZZED_CONCEPT="$CONCEPT"
break
Expand All @@ -147,15 +232,23 @@ if [ -n "$UNQUIZZED_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then
.quizzes[$concept] // [] |
map(select(.belt == $belt or .belt == "white")) |
first // null
' "$QUIZ_BANK")
' "$QUIZ_BANK" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading static question for $UNQUIZZED_CONCEPT: $STATIC_Q"
STATIC_Q="null"
fi

ESCAPED_CONCEPT=$(json_escape "$UNQUIZZED_CONCEPT")
ESCAPED_BELT=$(json_escape "$BELT")

if [ "$STATIC_Q" != "null" ] && [ -n "$STATIC_Q" ]; then
echo "{\"mode\":\"static\",\"concept\":\"$UNQUIZZED_CONCEPT\",\"reason\":\"New concept from this session — not yet quizzed.\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
exit 0
printf '{"mode":"static","concept":%s,"reason":"New concept from this session — not yet quizzed.","static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \
"$ESCAPED_CONCEPT" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT"
else
echo "{\"mode\":\"dynamic\",\"concept\":\"$UNQUIZZED_CONCEPT\",\"reason\":\"New concept from this session — no static question available, generate dynamically.\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
exit 0
printf '{"mode":"dynamic","concept":%s,"reason":"New concept from this session — no static question available, generate dynamically.","static_question":null,"belt":%s,"quiz_format":"%s"}\n' \
"$ESCAPED_CONCEPT" "$ESCAPED_BELT" "$QUIZ_FORMAT"
fi
exit 0
fi

# ─── PRIORITY 3: Least-quizzed lifetime concepts ───
Expand All @@ -166,20 +259,34 @@ if [ "$CONCEPTS_SEEN" != "[]" ]; then
.[] as $concept |
($history | [.[] | select(.concept == $concept)] | length) as $count |
{concept: $concept, count: $count}
' <<< "$CONCEPTS_SEEN" | jq -s 'sort_by(.count) | first | .concept // null' 2>/dev/null)
' <<< "$CONCEPTS_SEEN" 2>&1 | jq -s 'sort_by(.count) | first | .concept // null' 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed computing least-quizzed concept: $LEAST_QUIZZED"
LEAST_QUIZZED=""
fi
fi

if [ -n "$LEAST_QUIZZED" ] && [ "$LEAST_QUIZZED" != "null" ] && [ -f "$QUIZ_BANK" ]; then
STATIC_Q=$(jq -c --arg concept "$LEAST_QUIZZED" --arg belt "$BELT" '
.quizzes[$concept] // [] |
map(select(.belt == $belt or .belt == "white")) |
first // null
' "$QUIZ_BANK")
' "$QUIZ_BANK" 2>&1)
if [ $? -ne 0 ]; then
log_error "$SCRIPT_NAME" "jq failed reading static question for $LEAST_QUIZZED: $STATIC_Q"
STATIC_Q="null"
fi

ESCAPED_CONCEPT=$(json_escape "$LEAST_QUIZZED")
ESCAPED_BELT=$(json_escape "$BELT")

echo "{\"mode\":\"static\",\"concept\":\"$LEAST_QUIZZED\",\"reason\":\"Reinforcing least-practiced concept.\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
printf '{"mode":"static","concept":%s,"reason":"Reinforcing least-practiced concept.","static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \
"$ESCAPED_CONCEPT" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT"
exit 0
fi

# ─── FALLBACK: Dynamic generation ───
echo "{\"mode\":\"dynamic\",\"concept\":null,\"reason\":\"No specific concept to target — generate from current session context.\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
ESCAPED_BELT=$(json_escape "$BELT")
printf '{"mode":"dynamic","concept":null,"reason":"No specific concept to target — generate from current session context.","static_question":null,"belt":%s,"quiz_format":"%s"}\n' \
"$ESCAPED_BELT" "$QUIZ_FORMAT"
exit 0
Loading