mirror of
https://github.com/hanzoskill/harvey.git
synced 2026-10-30 21:03:52 +00:00
364 lines
12 KiB
Python
364 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = []
|
|
# ///
|
|
|
|
"""
|
|
Harvey Journey Tracker - Maintains continuity during walks and explorations.
|
|
Tracks locations, observations, directions, and schedules proactive check-ins.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
|
|
# State file location
|
|
STATE_DIR = Path(__file__).parent.parent / "state"
|
|
JOURNEY_FILE = STATE_DIR / "journey.json"
|
|
CHECKIN_FILE = STATE_DIR / "checkin_schedule.json"
|
|
|
|
# Default check-in intervals (minutes)
|
|
CHECKIN_INTERVALS = {
|
|
"walk": 5, # Every 5 minutes during walks
|
|
"restaurant": 10, # Every 10 minutes at restaurant
|
|
"waiting": 7, # Every 7 minutes while waiting
|
|
"bored": 15 # Every 15 minutes when just chatting
|
|
}
|
|
|
|
|
|
def load_journey() -> dict:
|
|
"""Load current journey state."""
|
|
if not JOURNEY_FILE.exists():
|
|
return {"active": False, "events": []}
|
|
try:
|
|
with open(JOURNEY_FILE) as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, KeyError):
|
|
return {"active": False, "events": []}
|
|
|
|
|
|
def save_journey(journey: dict) -> None:
|
|
"""Save journey state."""
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
with open(JOURNEY_FILE, "w") as f:
|
|
json.dump(journey, f, indent=2, default=str)
|
|
|
|
|
|
def start_journey(mode: str = "walk", start_location: str = None) -> dict:
|
|
"""Start a new journey/exploration."""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
journey = {
|
|
"active": True,
|
|
"mode": mode,
|
|
"started_at": now.isoformat(),
|
|
"start_location": start_location,
|
|
"current_location": start_location,
|
|
"last_direction": None,
|
|
"events": [],
|
|
"things_seen": [],
|
|
"mood": None,
|
|
"destinations_mentioned": [],
|
|
"last_checkin": now.isoformat(),
|
|
"checkin_count": 0
|
|
}
|
|
|
|
save_journey(journey)
|
|
schedule_next_checkin(mode)
|
|
return journey
|
|
|
|
|
|
def add_event(event_type: str, content: str, location: str = None) -> dict:
|
|
"""
|
|
Add an event to the journey timeline.
|
|
|
|
Event types:
|
|
- direction: Harvey gave a direction ("go right")
|
|
- observation: User saw something ("I see a park")
|
|
- location: User's current location
|
|
- mood: User expressed feeling
|
|
- destination: A place mentioned as potential destination
|
|
- action: User did something ("bought coffee")
|
|
"""
|
|
journey = load_journey()
|
|
|
|
if not journey.get("active"):
|
|
return {"error": "No active journey"}
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
event = {
|
|
"type": event_type,
|
|
"content": content,
|
|
"timestamp": now.isoformat(),
|
|
"location": location
|
|
}
|
|
|
|
journey["events"].append(event)
|
|
|
|
# Update specific trackers based on event type
|
|
if event_type == "location":
|
|
journey["current_location"] = content
|
|
elif event_type == "direction":
|
|
journey["last_direction"] = content
|
|
elif event_type == "observation":
|
|
journey["things_seen"].append(content)
|
|
# Keep last 10 observations
|
|
journey["things_seen"] = journey["things_seen"][-10:]
|
|
elif event_type == "mood":
|
|
journey["mood"] = content
|
|
elif event_type == "destination":
|
|
journey["destinations_mentioned"].append(content)
|
|
|
|
save_journey(journey)
|
|
return event
|
|
|
|
|
|
def get_context() -> dict:
|
|
"""
|
|
Get current journey context for Harvey to reference.
|
|
This is what Harvey "remembers" about the ongoing journey.
|
|
"""
|
|
journey = load_journey()
|
|
|
|
if not journey.get("active"):
|
|
return {"active": False}
|
|
|
|
# Calculate duration
|
|
started = datetime.fromisoformat(journey["started_at"])
|
|
now = datetime.now(timezone.utc)
|
|
duration_min = int((now - started).total_seconds() / 60)
|
|
|
|
# Get recent events (last 5)
|
|
recent_events = journey.get("events", [])[-5:]
|
|
|
|
# Build context summary
|
|
context = {
|
|
"active": True,
|
|
"mode": journey.get("mode"),
|
|
"duration_minutes": duration_min,
|
|
"start_location": journey.get("start_location"),
|
|
"current_location": journey.get("current_location"),
|
|
"last_direction": journey.get("last_direction"),
|
|
"things_seen": journey.get("things_seen", []),
|
|
"mood": journey.get("mood"),
|
|
"destinations_mentioned": journey.get("destinations_mentioned", []),
|
|
"recent_events": recent_events,
|
|
"event_count": len(journey.get("events", [])),
|
|
"checkin_count": journey.get("checkin_count", 0)
|
|
}
|
|
|
|
# Generate a natural summary for Harvey
|
|
summary_parts = []
|
|
|
|
if journey.get("start_location"):
|
|
summary_parts.append(f"Started at: {journey['start_location']}")
|
|
|
|
if journey.get("current_location") and journey["current_location"] != journey.get("start_location"):
|
|
summary_parts.append(f"Now at: {journey['current_location']}")
|
|
|
|
if journey.get("last_direction"):
|
|
summary_parts.append(f"Last direction: {journey['last_direction']}")
|
|
|
|
if journey.get("things_seen"):
|
|
summary_parts.append(f"Seen: {', '.join(journey['things_seen'][-3:])}")
|
|
|
|
if journey.get("mood"):
|
|
summary_parts.append(f"Mood: {journey['mood']}")
|
|
|
|
context["summary"] = " | ".join(summary_parts) if summary_parts else "Just started"
|
|
|
|
return context
|
|
|
|
|
|
def record_checkin() -> dict:
|
|
"""Record that a check-in happened."""
|
|
journey = load_journey()
|
|
|
|
if not journey.get("active"):
|
|
return {"error": "No active journey"}
|
|
|
|
journey["last_checkin"] = datetime.now(timezone.utc).isoformat()
|
|
journey["checkin_count"] = journey.get("checkin_count", 0) + 1
|
|
|
|
save_journey(journey)
|
|
schedule_next_checkin(journey.get("mode", "walk"))
|
|
|
|
return {"checkin_count": journey["checkin_count"]}
|
|
|
|
|
|
def schedule_next_checkin(mode: str) -> dict:
|
|
"""Schedule the next proactive check-in."""
|
|
interval = CHECKIN_INTERVALS.get(mode, 5)
|
|
now = datetime.now(timezone.utc)
|
|
next_checkin = now + timedelta(minutes=interval)
|
|
|
|
schedule = {
|
|
"scheduled_at": now.isoformat(),
|
|
"checkin_at": next_checkin.isoformat(),
|
|
"mode": mode,
|
|
"interval_minutes": interval
|
|
}
|
|
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
with open(CHECKIN_FILE, "w") as f:
|
|
json.dump(schedule, f, indent=2)
|
|
|
|
return schedule
|
|
|
|
|
|
def is_checkin_due() -> dict:
|
|
"""Check if a proactive check-in is due."""
|
|
if not CHECKIN_FILE.exists():
|
|
return {"due": False}
|
|
|
|
try:
|
|
with open(CHECKIN_FILE) as f:
|
|
schedule = json.load(f)
|
|
|
|
checkin_at = datetime.fromisoformat(schedule["checkin_at"])
|
|
now = datetime.now(timezone.utc)
|
|
|
|
return {
|
|
"due": now >= checkin_at,
|
|
"scheduled_for": schedule["checkin_at"],
|
|
"mode": schedule.get("mode"),
|
|
"overdue_minutes": max(0, int((now - checkin_at).total_seconds() / 60))
|
|
}
|
|
except (json.JSONDecodeError, KeyError):
|
|
return {"due": False}
|
|
|
|
|
|
def get_checkin_prompt() -> str:
|
|
"""Generate a contextual check-in prompt for Harvey."""
|
|
context = get_context()
|
|
|
|
if not context.get("active"):
|
|
return ""
|
|
|
|
checkin_count = context.get("checkin_count", 0)
|
|
mode = context.get("mode", "walk")
|
|
things_seen = context.get("things_seen", [])
|
|
last_direction = context.get("last_direction")
|
|
|
|
# Different prompts based on context
|
|
if mode == "walk":
|
|
if checkin_count == 0:
|
|
return "Und, bist du losgegangen? Was siehst du?"
|
|
elif last_direction and checkin_count < 3:
|
|
return f"Hey! Bist du {last_direction} gegangen? Wo bist du jetzt?"
|
|
elif things_seen:
|
|
last_seen = things_seen[-1]
|
|
return f"Bist du noch bei {last_seen}? Oder schon weiter?"
|
|
else:
|
|
prompts = [
|
|
"Hey, wo bist du gerade? Was siehst du?",
|
|
"Alles gut? Was passiert um dich herum?",
|
|
"Und? Irgendwas Interessantes entdeckt?",
|
|
"Wie sieht's aus? Beschreib mal, was du siehst!"
|
|
]
|
|
return prompts[checkin_count % len(prompts)]
|
|
|
|
elif mode == "restaurant":
|
|
prompts = [
|
|
"Schon bestellt? Was gibt's?",
|
|
"Wie schmeckt's?",
|
|
"Und, wie ist die Atmosphäre so?"
|
|
]
|
|
return prompts[checkin_count % len(prompts)]
|
|
|
|
return "Hey, wie läuft's?"
|
|
|
|
|
|
def end_journey(reason: str = "completed") -> dict:
|
|
"""End the current journey."""
|
|
journey = load_journey()
|
|
|
|
if not journey.get("active"):
|
|
return {"error": "No active journey"}
|
|
|
|
journey["active"] = False
|
|
journey["ended_at"] = datetime.now(timezone.utc).isoformat()
|
|
journey["end_reason"] = reason
|
|
|
|
# Calculate stats
|
|
started = datetime.fromisoformat(journey["started_at"])
|
|
ended = datetime.fromisoformat(journey["ended_at"])
|
|
journey["total_duration_minutes"] = int((ended - started).total_seconds() / 60)
|
|
journey["total_events"] = len(journey.get("events", []))
|
|
|
|
save_journey(journey)
|
|
|
|
# Clear check-in schedule
|
|
if CHECKIN_FILE.exists():
|
|
CHECKIN_FILE.unlink()
|
|
|
|
return journey
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Harvey Journey Tracker")
|
|
parser.add_argument("action", choices=[
|
|
"start", "event", "context", "checkin", "checkin-due",
|
|
"checkin-prompt", "end", "status"
|
|
], help="Action to perform")
|
|
parser.add_argument("--mode", "-m", default="walk", help="Journey mode")
|
|
parser.add_argument("--location", "-l", help="Location")
|
|
parser.add_argument("--type", "-t", help="Event type")
|
|
parser.add_argument("--content", "-c", help="Event content")
|
|
parser.add_argument("--reason", default="completed", help="End reason")
|
|
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.action == "start":
|
|
result = start_journey(args.mode, args.location)
|
|
msg = f"🚶 Journey started ({args.mode})"
|
|
elif args.action == "event":
|
|
if not args.type or not args.content:
|
|
print("Error: --type and --content required", file=sys.stderr)
|
|
sys.exit(1)
|
|
result = add_event(args.type, args.content, args.location)
|
|
msg = f"📝 Event recorded: {args.type}"
|
|
elif args.action == "context":
|
|
result = get_context()
|
|
msg = result.get("summary", "No active journey")
|
|
elif args.action == "checkin":
|
|
result = record_checkin()
|
|
msg = f"✅ Check-in #{result.get('checkin_count', 0)}"
|
|
elif args.action == "checkin-due":
|
|
result = is_checkin_due()
|
|
msg = "⏰ Check-in due!" if result.get("due") else "⏳ Not yet"
|
|
elif args.action == "checkin-prompt":
|
|
prompt = get_checkin_prompt()
|
|
result = {"prompt": prompt}
|
|
msg = prompt or "No active journey"
|
|
elif args.action == "end":
|
|
result = end_journey(args.reason)
|
|
if "error" not in result:
|
|
msg = f"🏁 Journey ended: {result.get('total_duration_minutes', 0)} min, {result.get('total_events', 0)} events"
|
|
else:
|
|
msg = result["error"]
|
|
elif args.action == "status":
|
|
result = get_context()
|
|
if result.get("active"):
|
|
msg = f"🚶 Active: {result.get('duration_minutes', 0)} min | {result.get('summary', '')}"
|
|
else:
|
|
msg = "💤 No active journey"
|
|
else:
|
|
result = {"error": "Unknown action"}
|
|
msg = "Unknown action"
|
|
|
|
if args.json:
|
|
print(json.dumps(result, indent=2, default=str))
|
|
else:
|
|
print(msg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|