From df0cee1b8a895a106edffb04b30a2643d2ccc322 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:10:01 +0000 Subject: [PATCH 1/9] docs: add @hassan1731996 to contributors --- CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7867026a..3069cb2a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,7 +15,7 @@ Format: - **@username** — ability-name ([ability-name](community/ability-name/ - **[@Rizwan-algoryc](https://github.com/Rizwan-algoryc)** — slow-music ([slow-music](community/slow-music/)) - **[@engrumair842-arch](https://github.com/engrumair842-arch)** — reddit-daily-digest ([reddit-daily-digest](community/reddit-daily-digest/)), smart-sous-chef ([smart-sous-chef](community/smart-sous-chef/)) - **[@samsonadmasu](https://github.com/samsonadmasu)** — voice-unit-converter ([voice-unit-converter](community/voice-unit-converter/)), food-water-log ([food-water-log](community/food-water-log/)), gmail-connector ([gmail-connector](community/gmail-connector/)), google-tasks ([google-tasks](community/google-tasks/)), traffic-travel-time ([traffic-travel-time](community/traffic-travel-time/)) -- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)) +- **[@hassan1731996](https://github.com/hassan1731996)** — daily-briefing ([daily-briefing](community/daily-briefing/)), voice-journal ([voice-journal](community/voice-journal/)), whatsapp-messenger ([whatsapp-messenger](community/whatsapp-messenger/)), alarm-timer ([alarm-timer](community/alarm-timer/)), flight-booking ([flight-booking](community/flight-booking/)), debate-partner ([debate-partner](community/debate-partner/)), conversation-insights-coach ([conversation-insights-coach](community/conversation-insights-coach/)), curiosity-queue ([curiosity-queue](community/curiosity-queue/)) - **[@BhargavTelu](https://github.com/BhargavTelu)** — grocery-list-manager ([grocery-list-manager](community/grocery-list-manager/)), package-tracker ([package-tracker](community/package-tracker/)) - **[@ArturKozhushnyi](https://github.com/ArturKozhushnyi)** — coin-flipper ([coin-flipper](community/coin-flipper/)), Bedtime-Wind-Down ([Bedtime-Wind-Down](community/Bedtime-Wind-Down/)), Twilio-SMS ([Twilio-SMS](community/Twilio-SMS/)) - **[@ammyyou112](https://github.com/ammyyou112)** — dad-joke-teller ([dad-joke-teller](community/dad-joke-teller/)), youtube-search-play ([youtube-search-play](community/youtube-search-play/)), google-daily-brief ([google-daily-brief](community/google-daily-brief/)) From b4d154cf0c1872301d7001d6254f8c110438628c Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 26 Apr 2026 15:02:52 +0500 Subject: [PATCH 2/9] Add Decision Journal ability Passive background daemon captures significant decisions from natural conversation and stores them with category, alternatives, and significance. Interactive skill enables outcome recording, guided reflection sessions, LLM-synthesized pattern analysis, stale-outcome nudges after 14 days, and daily briefings. Two-phase detection filters trivial and third-party decisions; 60% dedup prevents recapture. --- community/decision-journal/README.md | 91 +++ community/decision-journal/__init__.py | 0 community/decision-journal/background.py | 460 +++++++++++ community/decision-journal/main.py | 924 +++++++++++++++++++++++ 4 files changed, 1475 insertions(+) create mode 100644 community/decision-journal/README.md create mode 100644 community/decision-journal/__init__.py create mode 100644 community/decision-journal/background.py create mode 100644 community/decision-journal/main.py diff --git a/community/decision-journal/README.md b/community/decision-journal/README.md new file mode 100644 index 00000000..9764706e --- /dev/null +++ b/community/decision-journal/README.md @@ -0,0 +1,91 @@ +# Decision Journal + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-hassan1731996-blue?style=flat-square) + +Passively captures the decisions you make in conversation — *"I decided to take the startup job"*, *"I'm going with the Toyota"*, *"I'm torn between two offers"* — and stores them with context. Later, review your decisions, record how they turned out, run reflective sessions, and hear patterns in how you decide. + +## What It Does + +A passive background daemon listens every 15 seconds. When you make or deliberate on a significant decision, it quietly logs it. Ask anytime to review your journal, record outcomes, reflect on a specific choice, or get an LLM-synthesized read on your decision-making style. + +## Trigger Words + +- `decision journal` / `my decisions` / `my decision journal` +- `what decisions have I made` / `show my decisions` +- `how did that decision turn out` / `record an outcome` / `decision outcome` +- `my decision patterns` / `what patterns do you see in my decisions` +- `reflect on a decision` / `help me reflect on a decision` +- `add a decision` / `log a decision` +- `clear my decisions` / `decision stats` +- `notify me when you capture a decision` / `stop notifying me about decisions` + +## How It Works + +1. **Background daemon starts automatically** when you connect a session +2. Every 15 seconds it scans new messages for decision-making language +3. A two-phase filter catches genuine significant decisions (fast keyword scan → LLM significance classifier) +4. Trivial choices ("I'll grab coffee") are filtered out — only meaningful decisions are captured +5. Say *"decision journal"* to review your queue, record outcomes, or explore patterns +6. After 14 days without an outcome on a major decision, you'll get a gentle follow-up nudge + +## Features + +- **Passive capture** — just talk naturally, no trigger word needed to log decisions +- **Two-phase detection** — keyword filter + LLM classifier to avoid trivial or third-party decisions +- **Deliberation tracking** — captures "I'm torn between X and Y" before the decision is made +- **Outcome recording** — mark decisions as good call, bad call, mixed, or too soon to tell +- **Reflection sessions** — 2-round guided reflection with thoughtful follow-up questions +- **Pattern analysis** — LLM synthesizes 2-3 concrete patterns from your decision history (needs 5+ decisions) +- **Stale-outcome nudge** — gentle daily reminder for HIGH significance decisions after 14 days +- **Startup notification** — hear pending-outcome count when you reconnect (if 2+) +- **Daily briefing** — reminder at start of new day if decisions need outcomes +- **Manual add** — `add a decision: [topic]` with category and alternative prompts +- **Category grouping** — career / financial / health / relationship / personal / other +- **Persistent** — journal survives session disconnects +- **Dedup** — 60% word-overlap check prevents capturing the same decision twice + +## What Gets Captured + +| Captured | Not Captured | +|---|---| +| "I decided to take the startup job" | "Should I take the job?" (asking agent) | +| "I'm going with the Toyota over the Honda" | "I'll grab coffee" (trivial) | +| "I've made up my mind — I'm moving to Austin" | "She decided to quit" (third party) | +| "I'm torn between two job offers" | "I'll check my email" (daily task) | +| "I committed to starting therapy" | "What would you do?" (directed at agent) | + +## Example Conversation + +**User:** *(talking naturally)* "I've made up my mind — I'm going with the startup offer over the corporate one." + +*(15 seconds pass — daemon captures it silently)* + +**User:** "Decision journal." + +**Coach:** "You have 1 decision logged. 1 career decision: take the startup offer over the corporate one. It still needs an outcome. Want me to review it, record an outcome, or show your decision patterns?" + +**User:** "Reflect on it." + +**Coach:** "Looking back — what made the startup feel like the right call over the stability of the corporate offer?" + +**User:** "Honestly, I just couldn't see myself being happy in a big company." + +**Coach:** "That's a really self-aware answer — knowing what you need from your work environment is often the clearest signal. The logical arguments can go either way, but your gut was telling you something about fit, not just opportunity." + +*(Two weeks later, unprompted)* + +**Coach:** "It's been over 2 weeks since you decided to take the startup offer. How's that going? Say 'decision journal' to update it." + +## Storage + +All data is stored in `decision_journal.json`: +- **Decisions** — up to 50 active items (pending + resolved) +- **History** — up to 100 archived items +- **Settings** — notify_on_capture, last_brief_date, last_nudge_date +- **Stats** — total captured and total with outcomes +- **Meta** — session pointer for reconnect deduplication + +## Setup + +No external API keys required. Uses your agent's built-in LLM for decision classification, reflection, and pattern analysis. diff --git a/community/decision-journal/__init__.py b/community/decision-journal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/decision-journal/background.py b/community/decision-journal/background.py new file mode 100644 index 00000000..c74ffefd --- /dev/null +++ b/community/decision-journal/background.py @@ -0,0 +1,460 @@ +import json +import re +from datetime import datetime, timedelta + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# ============================================================================= +# DECISION JOURNAL — Background Daemon +# Auto-starts on session connect. Polls conversation history every 15 seconds. +# Detects when the user makes or deliberates on significant decisions and +# silently captures them to decision_journal.json. Also handles stale-outcome +# nudges (14-day follow-up on high-significance decisions without outcomes) +# and daily briefings when pending-outcome decisions exist. +# ============================================================================= + +JOURNAL_FILE = "decision_journal.json" +POLL_INTERVAL = 15.0 +SAVE_EVERY_N_POLLS = 20 +MAX_LLM_CALLS_PER_POLL = 3 +STARTUP_NOTIFY_MIN = 2 # min pending-outcome decisions to announce at startup +STALE_OUTCOME_DAYS = 14 # days before nudging about an outstanding outcome +DAILY_BRIEF_MIN = 2 # min pending-outcome decisions to trigger daily brief +MAX_PERSONALITY_INJECTIONS = 3 + +DECISION_TRIGGERS = [ + # Committed decisions + "i decided", "i've decided", "i have decided", + "i made a decision", "i've made my decision", "i made my decision", + "i'm going with", "i'll go with", "going with", + "i chose", "i've chosen", "i picked", "i've picked", + "i made up my mind", "made my choice", + "going ahead with", "i'm going ahead", + "i went with", "i settled on", "i landed on", + "i committed to", + # Active deliberation + "i'm torn between", "torn between", + "can't decide between", "can't choose between", + "going back and forth", "back and forth on", + "weighing", "on the fence about", + "i don't know whether to", "not sure whether to", + "i'm leaning towards", "leaning toward", +] + +SKIP_PHRASES = [ + # Direct requests to agent — not personal decisions + "should i", "what should i", "what do you think i should", + "would you decide", "help me decide", "can you decide", + "do you think i should", "what would you do", + # Hypothetical / indirect + "if i had to decide", "hypothetically", + "let's say i decided", "what if i decided", + # Third-party — not about the user + "he decided", "she decided", "they decided", + "he chose", "she chose", "they chose", +] + + +def _new_state() -> dict: + """Return fresh mutable state dict — lives as a local var, never on self.""" + return { + "last_processed_index": 0, + "polls_since_save": 0, + "notify_on_capture": False, + "startup_notified": False, + "last_brief_date": "", + "briefed_today": False, + "nudge_checked_today": False, + "personality_injected_count": 0, + } + + +def _empty_journal_data() -> dict: + """Single shared factory — used in all three _load_journal paths.""" + return { + "decisions": [], + "history": [], + "settings": { + "notify_on_capture": False, + "last_brief_date": "", + "last_nudge_date": "", + }, + "stats": {"total_captured": 0, "total_with_outcomes": 0}, + "meta": {"last_processed_length": 0}, + } + + +class DecisionJournalBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + # Do not change following tag of register capability + #{{register capability}} + + # ------------------------------------------------------------------ + # File I/O + # ------------------------------------------------------------------ + + async def _load_journal(self) -> dict: + try: + exists = await self.capability_worker.check_if_file_exists(JOURNAL_FILE, False) + if not exists: + return _empty_journal_data() + raw = await self.capability_worker.read_file(JOURNAL_FILE, False) + if not raw or not raw.strip(): + return _empty_journal_data() + return json.loads(raw) + except Exception as e: + self.worker.editor_logging_handler.error(f"[DecisionJournal] Load error: {e}") + return _empty_journal_data() + + async def _save_journal(self, data: dict): + try: + exists = await self.capability_worker.check_if_file_exists(JOURNAL_FILE, False) + if exists: + await self.capability_worker.delete_file(JOURNAL_FILE, False) + await self.capability_worker.write_file( + JOURNAL_FILE, json.dumps(data, indent=2), False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[DecisionJournal] Save error: {e}") + + async def _restore_from_file(self, s: dict) -> dict: + """Restore persisted state into s on session reconnect.""" + data = await self._load_journal() + settings = data.get("settings", {}) + meta = data.get("meta", {}) + s["notify_on_capture"] = settings.get("notify_on_capture", False) + s["last_brief_date"] = settings.get("last_brief_date", "") + s["last_processed_index"] = meta.get("last_processed_length", 0) + return data + + # ------------------------------------------------------------------ + # Detection helpers (sync) + # ------------------------------------------------------------------ + + def _skip_phrase_filter(self, text: str) -> bool: + """Returns True if text contains a skip phrase — abort before Phase 1.""" + text_lower = text.lower() + return any(phrase in text_lower for phrase in SKIP_PHRASES) + + def _phase1_fast_filter(self, text: str) -> bool: + """Returns True if any decision trigger keyword is present.""" + text_lower = text.lower() + return any(trigger in text_lower for trigger in DECISION_TRIGGERS) + + def _strip_json_fences(self, raw: str) -> str: + raw = raw.strip() + if raw.startswith("```"): + lines = raw.splitlines() + raw = "\n".join( + lines[1:-1] if lines[-1].strip() == "```" else lines[1:] + ).strip() + return raw + + def _phase2_llm_extract(self, text: str) -> dict | None: + """Phase 2: LLM extraction. Sync — text_to_text_response is sync.""" + prompt = ( + f"The user said: '{text}'\n\n" + "Does this express a genuine, significant personal decision made BY the user?\n\n" + "YES:\n" + ' "I decided to quit my job"\n' + ' "I\'m going with the Toyota over the Honda"\n' + ' "I\'ve made up my mind — I\'m moving to Austin"\n' + ' "I\'m torn between two job offers"\n' + ' "I committed to starting therapy"\n\n' + "NO:\n" + ' "I\'m going to grab coffee" (trivial — not significant)\n' + ' "Should I take the job?" (asking the agent — not a decision)\n' + ' "He decided to leave" (third party — not the user)\n' + ' "I\'ll check my email" (trivial daily task)\n\n' + "Rule: Only significant personal decisions by THIS user — career, financial, health, " + "relationship, personal. Trivial daily choices → significance: low → capture: false.\n\n" + "Return ONLY valid JSON, no markdown:\n" + '{"capture": true, "summary": "concise max 150 chars", ' + '"decision_type": "made" or "deliberating", ' + '"category": "career" or "financial" or "health" or "relationship" or "personal" or "other", ' + '"alternatives": ["option A", "option B"], ' + '"significance": "medium" or "high"}\n' + 'OR: {"capture": false}' + ) + try: + raw = self.capability_worker.text_to_text_response(prompt) + cleaned = self._strip_json_fences(raw) + parsed = json.loads(cleaned) + if not parsed.get("capture"): + return None + summary = parsed.get("summary", "").strip()[:150] + if not summary: + return None + return { + "summary": summary, + "decision_type": parsed.get("decision_type", "made"), + "category": parsed.get("category", "other"), + "alternatives": parsed.get("alternatives", []), + "significance": parsed.get("significance", "medium"), + } + except Exception as e: + self.worker.editor_logging_handler.error(f"[DecisionJournal] Phase 2 parse error: {e}") + return None + + def _is_duplicate(self, summary: str, data: dict) -> bool: + """60% word-overlap dedup vs all decisions + 50% vs last 20 history items.""" + words_new = set(re.findall(r'\b[a-z]+\b', summary.lower())) + if not words_new: + return False + + for item in data.get("decisions", []): + words_ex = set(re.findall(r'\b[a-z]+\b', item.get("summary", "").lower())) + if words_ex: + overlap = len(words_new & words_ex) / max(len(words_new), len(words_ex), 1) + if overlap >= 0.60: + return True + + for item in data.get("history", [])[-20:]: + words_h = set(re.findall(r'\b[a-z]+\b', item.get("summary", "").lower())) + if words_h: + overlap = len(words_new & words_h) / max(len(words_new), len(words_h), 1) + if overlap >= 0.50: + return True + + return False + + # ------------------------------------------------------------------ + # Queue management + # ------------------------------------------------------------------ + + async def _add_to_decisions( + self, text: str, summary: str, decision_type: str, + category: str, alternatives: list, significance: str, data: dict + ) -> dict: + entry = { + "id": str(int(datetime.now().timestamp() * 1000)), + "summary": summary, + "raw": text[:500], + "decision_type": decision_type, + "category": category, + "alternatives": alternatives, + "significance": significance, + "captured_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + "date": datetime.now().strftime("%Y-%m-%d"), + "outcome": None, + "outcome_at": None, + "outcome_sentiment": None, + "reflection": None, + "status": "pending_outcome", + } + data["decisions"].append(entry) + data["stats"]["total_captured"] = data["stats"].get("total_captured", 0) + 1 + + # Overflow: max 50 items — archive oldest outcome_recorded first, then oldest + if len(data["decisions"]) > 50: + resolved = [d for d in data["decisions"] if d.get("status") == "outcome_recorded"] + oldest = min( + resolved if resolved else data["decisions"], + key=lambda x: x.get("captured_at", "") + ) + data["decisions"].remove(oldest) + data.setdefault("history", []).append(oldest) + + # History cap + if len(data.get("history", [])) > 100: + data["history"] = data["history"][-100:] + + return data + + # ------------------------------------------------------------------ + # Main daemon loop + # ------------------------------------------------------------------ + + async def watch_loop(self): + s = _new_state() + self.worker.editor_logging_handler.info("[DecisionJournal] daemon started") + cached_data = await self._restore_from_file(s) + + # Startup notification: pending-outcome decisions + pending_outcome = [ + d for d in cached_data.get("decisions", []) + if d.get("status") == "pending_outcome" + ] + if len(pending_outcome) >= STARTUP_NOTIFY_MIN and not s["startup_notified"]: + count = len(pending_outcome) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak( + f"Just so you know — you have {count} " + f"{'decision' if count == 1 else 'decisions'} waiting for outcomes. " + "Say 'decision journal' anytime to update them." + ) + s["startup_notified"] = True + + while True: + try: + history = self.capability_worker.get_full_message_history() + history = history or [] + current_length = len(history) + + # First-run guard: skip old messages on first session + if s["last_processed_index"] == 0 and current_length > 10: + s["last_processed_index"] = current_length - 10 + + # Shrinkage guard: history was trimmed + if s["last_processed_index"] > current_length: + s["last_processed_index"] = max(0, current_length - 3) + + new_msgs = history[s["last_processed_index"]:] + s["last_processed_index"] = current_length + + llm_calls_this_poll = 0 + for msg in new_msgs: + if msg.get("role") != "user": + continue + text = msg.get("content", "") + if not isinstance(text, str): + continue + text = text.strip() + if len(text.split()) < 5: + continue + + if self._skip_phrase_filter(text): + continue + if not self._phase1_fast_filter(text): + continue + if llm_calls_this_poll >= MAX_LLM_CALLS_PER_POLL: + break + + result = self._phase2_llm_extract(text) + llm_calls_this_poll += 1 + if result is None: + continue + + data = await self._load_journal() + if self._is_duplicate(result["summary"], data): + self.worker.editor_logging_handler.info( + f"[DecisionJournal] Duplicate skipped: {result['summary']}" + ) + continue + + data = await self._add_to_decisions( + text, result["summary"], result["decision_type"], + result["category"], result["alternatives"], result["significance"], data + ) + + # Persist pointer + data.setdefault("meta", {})["last_processed_length"] = s["last_processed_index"] + await self._save_journal(data) + s["polls_since_save"] = 0 + + self.worker.editor_logging_handler.info( + f"[DecisionJournal] Captured [{result['category']}/{result['significance']}]: " + f"{result['summary']}" + ) + + # Personality injection (cap at MAX_PERSONALITY_INJECTIONS) + if s["personality_injected_count"] < MAX_PERSONALITY_INJECTIONS: + self.capability_worker.update_personality_agent_prompt( + f"[Decision noted]: {result['summary']}" + ) + s["personality_injected_count"] += 1 + + # Real-time notification (if enabled) + if s["notify_on_capture"]: + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak( + f"Just noted a decision: {result['summary']}. " + "Say 'decision journal' anytime to reflect on it." + ) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[DecisionJournal] Loop error: {e}") + + # ------------------------------------------------------------------ + # Stale-outcome nudge (once per day — HIGH significance, 14+ days old) + # ------------------------------------------------------------------ + today = datetime.now().strftime("%Y-%m-%d") + if not s["nudge_checked_today"]: + try: + stale_cutoff = ( + datetime.now() - timedelta(days=STALE_OUTCOME_DAYS) + ).strftime("%Y-%m-%d") + data_fresh = await self._load_journal() + stale = [ + d for d in data_fresh.get("decisions", []) + if d.get("status") == "pending_outcome" + and d.get("significance") == "high" + and d.get("date", "") <= stale_cutoff + ] + last_nudge = data_fresh["settings"].get("last_nudge_date", "") + if stale and last_nudge != today: + oldest = min(stale, key=lambda x: x.get("date", "")) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak( + f"It's been a while — over {STALE_OUTCOME_DAYS} days ago you decided " + f"to {oldest['summary']}. How's that going? " + "Say 'decision journal' to update it." + ) + data_fresh["settings"]["last_nudge_date"] = today + await self._save_journal(data_fresh) + s["nudge_checked_today"] = True + except Exception as e: + self.worker.editor_logging_handler.error( + f"[DecisionJournal] Stale nudge error: {e}" + ) + + # ------------------------------------------------------------------ + # Daily briefing (new day + enough pending-outcome decisions) + # ------------------------------------------------------------------ + if today != s["last_brief_date"] and not s["briefed_today"]: + try: + data_fresh = await self._load_journal() + pending = [ + d for d in data_fresh.get("decisions", []) + if d.get("status") == "pending_outcome" + ] + if len(pending) >= DAILY_BRIEF_MIN: + count = len(pending) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak( + f"New day! You have {count} " + f"{'decision' if count == 1 else 'decisions'} without outcomes recorded. " + "Say 'decision journal' whenever you're ready to reflect." + ) + data_fresh["settings"]["last_brief_date"] = today + await self._save_journal(data_fresh) + s["last_brief_date"] = today + s["briefed_today"] = True + except Exception as e: + self.worker.editor_logging_handler.error( + f"[DecisionJournal] Daily brief error: {e}" + ) + + # ------------------------------------------------------------------ + # Periodic settings re-sync (~5 minutes) + # ------------------------------------------------------------------ + s["polls_since_save"] += 1 + if s["polls_since_save"] >= SAVE_EVERY_N_POLLS: + try: + fresh = await self._load_journal() + s["notify_on_capture"] = fresh["settings"].get("notify_on_capture", False) + s["last_brief_date"] = fresh["settings"].get( + "last_brief_date", s["last_brief_date"] + ) + fresh.setdefault("meta", {})["last_processed_length"] = s["last_processed_index"] + await self._save_journal(fresh) + s["polls_since_save"] = 0 + except Exception: + pass + + await self.worker.session_tasks.sleep(POLL_INTERVAL) + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.background_daemon_mode = background_daemon_mode + self.worker.session_tasks.create(self.watch_loop()) diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py new file mode 100644 index 00000000..7d678b97 --- /dev/null +++ b/community/decision-journal/main.py @@ -0,0 +1,924 @@ +import json +import random +import re +from datetime import datetime +from typing import Optional + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# ============================================================================= +# DECISION JOURNAL — Interactive Skill +# Triggered by hotwords like "decision journal" or "what decisions have I made". +# Reads from decision_journal.json (written by background.py) and lets the user +# review past decisions, record outcomes, run reflective sessions, surface +# decision-making patterns, add items manually, and manage the journal. +# ============================================================================= + +JOURNAL_FILE = "decision_journal.json" +REFLECT_DEPTH_CAP = 2 # max reflective exchanges per session +EXPLORE_DEPTH_CAP = 3 # max recursive explores per session +PATTERN_MIN_DECISIONS = 5 # min decisions needed for pattern analysis + +HOTWORDS = { + "decision journal", "my decisions", "my decision journal", + "what decisions have i made", "show my decisions", + "my recent decisions", "decisions i've made", + "how did that decision turn out", "update a decision", + "record an outcome", "decision outcome", + "how have i been deciding", "my decision patterns", + "what patterns do you see in my decisions", + "reflect on a decision", "help me reflect on a decision", + "add a decision", "log a decision", + "clear my decisions", + "notify me when you capture a decision", + "stop notifying me about decisions", + "decision stats", "my decision stats", +} + +# Whole-word exit detection — "no, reflect on the second one" must NOT exit +_EXIT_PATTERN = re.compile( + r'\b(stop|exit|quit|done|cancel|bye|goodbye|never\s*mind|no\s*thanks|' + r"that'?s\s*all|nothing|nah)\b", + re.IGNORECASE, +) + +ORDINALS = { + "first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, + "1st": 1, "2nd": 2, "3rd": 3, "4th": 4, "5th": 5, + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, +} + +CATEGORY_ORDER = ["career", "financial", "health", "relationship", "personal", "other"] + +SENTIMENT_MAP = { + "good": "positive", "great": "positive", "right": "positive", + "yes": "positive", "worked": "positive", "worth it": "positive", + "positive": "positive", + "bad": "negative", "wrong": "negative", "mistake": "negative", + "regret": "negative", "no": "negative", "negative": "negative", + "mixed": "mixed", "okay": "mixed", "so-so": "mixed", "alright": "mixed", + "too soon": "too_soon", "not yet": "too_soon", "still": "too_soon", + "early": "too_soon", +} + + +def _empty_journal_data() -> dict: + """Single shared factory — used in all three _load_journal paths.""" + return { + "decisions": [], + "history": [], + "settings": { + "notify_on_capture": False, + "last_brief_date": "", + "last_nudge_date": "", + }, + "stats": {"total_captured": 0, "total_with_outcomes": 0}, + "meta": {"last_processed_length": 0}, + } + + +def _infer_category(text: str) -> str: + """Lightweight keyword-based category inference for manually-added decisions.""" + t = text.lower() + if any(w in t for w in ("job", "career", "work", "promotion", "startup", "company", "hire", "fired", "quit", "resign")): + return "career" + if any(w in t for w in ("money", "invest", "buy", "purchase", "save", "loan", "rent", "mortgage", "financial", "salary", "budget")): + return "financial" + if any(w in t for w in ("health", "doctor", "surgery", "diet", "exercise", "gym", "therapy", "medication", "medical")): + return "health" + if any(w in t for w in ("relationship", "partner", "marry", "divorce", "friend", "family", "date", "move in", "break up")): + return "relationship" + if any(w in t for w in ("learn", "study", "hobby", "travel", "move", "habit", "routine", "personal")): + return "personal" + return "other" + + +class DecisionJournalCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Do not change following tag of register capability + #{{register capability}} + + # ------------------------------------------------------------------ + # Hotword matching + # ------------------------------------------------------------------ + + def does_match(self, text: str) -> bool: + t = text.lower().strip() + return any(hw in t for hw in HOTWORDS) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _is_exit(self, text: str) -> bool: + """Whole-word exit check — standalone 'no' exits, 'no, reflect on it' does not.""" + if not text or not text.strip(): + return True + stripped = text.strip().rstrip(".,!?").strip().lower() + if stripped == "no": + return True + return bool(_EXIT_PATTERN.search(text)) + + def _pending_outcome(self, data: dict) -> list: + return [d for d in data.get("decisions", []) if d.get("status") == "pending_outcome"] + + def _outcome_recorded(self, data: dict) -> list: + return [d for d in data.get("decisions", []) if d.get("status") == "outcome_recorded"] + + def _classify_intent(self, text: str) -> str: + t = text.lower() + # Destructive first + if any(kw in t for kw in ("clear", "delete", "wipe", "remove")) and "all" in t: + return "CLEAR_ALL" + # Toggle notifications + if any(kw in t for kw in ("stop notif", "no notif", "disable notif")): + return "TOGGLE_NOTIFY_OFF" + if any(kw in t for kw in ("notify me", "let me know when", "real-time", "real time")): + return "TOGGLE_NOTIFY_ON" + # Pattern analysis + if any(kw in t for kw in ("pattern", "how have i been deciding", "tendency", "tendencies", "what do you see")): + return "PATTERN" + # Outcome recording + if any(kw in t for kw in ("outcome", "how did it go", "how did that", "update a decision", "turned out", "record an outcome")): + return "OUTCOME" + # Reflection + if any(kw in t for kw in ("reflect", "help me think", "why did i", "dive into")): + return "REFLECT" + # History + if any(kw in t for kw in ("history", "past decisions", "already resolved", "already answered")): + return "HISTORY" + # Add manually + if any(kw in t for kw in ("add a decision", "log a decision", "record a decision", "save a decision")): + return "ADD" + # Stats + if any(kw in t for kw in ("stats", "how many decisions", "decision count")): + return "STATS" + # Default + return "LIST" + + def _select_decision(self, decisions: list, hint: str) -> Optional[dict]: + """ + Pick a decision from hint text. + Priority: explicit number → ordinal word → 'random' → keyword overlap → first pending_outcome → first item. + """ + if not decisions: + return None + t = hint.lower() + + # Explicit digit + num_match = re.search(r'\b(\d+)\b', hint) + if num_match: + idx = int(num_match.group(1)) - 1 + if 0 <= idx < len(decisions): + return decisions[idx] + + # Ordinal words + for word, idx in ORDINALS.items(): + if word in t: + real_idx = idx - 1 + if 0 <= real_idx < len(decisions): + return decisions[real_idx] + + # Random + if "random" in t or "surprise" in t: + return random.choice(decisions) + + # Keyword overlap + hint_words = set(re.findall(r'\b[a-z]+\b', t)) + best_item, best_overlap = None, 0 + for item in decisions: + item_words = set(re.findall(r'\b[a-z]+\b', item.get("summary", "").lower())) + overlap = len(hint_words & item_words) + if overlap > best_overlap: + best_overlap = overlap + best_item = item + if best_item and best_overlap > 0: + return best_item + + # Prefer pending_outcome, else first + pending = [d for d in decisions if d.get("status") == "pending_outcome"] + return pending[0] if pending else decisions[0] + + def _build_decision_list(self, decisions: list) -> str: + """Flat numbered list, capped at 10 for voice readability.""" + capped = decisions[:10] + parts = [f"{i + 1}. {item['summary']}" for i, item in enumerate(capped)] + result = ". ".join(parts) + if len(decisions) > 10: + result += f". And {len(decisions) - 10} more." + return result + + def _build_grouped_list(self, decisions: list) -> str: + """Group by category for clarity; fall back to flat list if single category.""" + capped = decisions[:10] + groups: dict = {} + for item in capped: + cat = item.get("category", "other") + if cat not in groups: + groups[cat] = [] + groups[cat].append(item) + + if len(groups) == 1: + return self._build_decision_list(decisions) + + parts = [] + idx = 1 + for cat in CATEGORY_ORDER: + if cat not in groups: + continue + group_items = groups[cat] + n = len(group_items) + heading = f"{n} {cat} {'decision' if n == 1 else 'decisions'}" + nums = [f"{idx + j}. {item['summary']}" for j, item in enumerate(group_items)] + parts.append(f"{heading}: {'. '.join(nums)}") + idx += n + + result = ". ".join(parts) + if len(decisions) > 10: + result += f". And {len(decisions) - 10} more." + return result + + def _infer_outcome_sentiment(self, reply: str) -> str: + """Map user's natural language reply to a sentiment label.""" + t = reply.lower() + for keyword, sentiment in SENTIMENT_MAP.items(): + if keyword in t: + return sentiment + # If reply sounds positive in spirit + if any(w in t for w in ("love", "happy", "glad", "thrilled", "perfect", "amazing")): + return "positive" + if any(w in t for w in ("hate", "regret", "awful", "terrible", "horrible")): + return "negative" + return "mixed" + + def _strip_json_fences(self, raw: str) -> str: + raw = raw.strip() + if raw.startswith("```"): + lines = raw.splitlines() + raw = "\n".join( + lines[1:-1] if lines[-1].strip() == "```" else lines[1:] + ).strip() + return raw + + # ------------------------------------------------------------------ + # File I/O + # ------------------------------------------------------------------ + + async def _load_journal(self) -> dict: + try: + exists = await self.capability_worker.check_if_file_exists(JOURNAL_FILE, False) + if not exists: + return _empty_journal_data() + raw = await self.capability_worker.read_file(JOURNAL_FILE, False) + if not raw or not raw.strip(): + return _empty_journal_data() + return json.loads(raw) + except Exception as e: + self.worker.editor_logging_handler.error(f"[DecisionJournal] Load error: {e}") + return _empty_journal_data() + + async def _save_journal(self, data: dict): + try: + exists = await self.capability_worker.check_if_file_exists(JOURNAL_FILE, False) + if exists: + await self.capability_worker.delete_file(JOURNAL_FILE, False) + await self.capability_worker.write_file( + JOURNAL_FILE, json.dumps(data, indent=2), False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[DecisionJournal] Save error: {e}") + + # ------------------------------------------------------------------ + # Intent handlers + # ------------------------------------------------------------------ + + async def _handle_list(self, data: dict): + all_decisions = data.get("decisions", []) + + if not all_decisions: + await self.capability_worker.speak( + "Your decision journal is empty — just talk naturally and I'll capture " + "your decisions as you make them." + ) + return + + pending = self._pending_outcome(data) + count = len(all_decisions) + pending_count = len(pending) + + grouped = self._build_grouped_list(all_decisions) + msg = f"You have {count} {'decision' if count == 1 else 'decisions'} logged. {grouped}." + if pending_count: + msg += ( + f" {pending_count} {'still need' if pending_count > 1 else 'still needs'} " + "an outcome recorded." + ) + await self.capability_worker.speak(msg) + + await self.capability_worker.speak( + "Want me to review one, record an outcome, or show your decision patterns? " + "Say a number, a keyword, or stop." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + + # Route reply + r = reply.lower() + if any(kw in r for kw in ("outcome", "how did", "turned out", "record")): + await self._handle_outcome(data, reply) + elif any(kw in r for kw in ("pattern", "tendency", "how have")): + await self._handle_pattern(data) + elif any(kw in r for kw in ("reflect", "why did", "think through")): + await self._handle_reflect(data, reply) + else: + await self._handle_explore(data, reply) + + async def _handle_explore(self, data: dict, hint: str = "", depth: int = 0): + # Reload fresh data to catch any daemon additions + data = await self._load_journal() + + if depth >= EXPLORE_DEPTH_CAP: + await self.capability_worker.speak( + "You've been on quite a decision journey! Come back anytime to keep reflecting." + ) + return + + all_decisions = data.get("decisions", []) + if not all_decisions: + await self.capability_worker.speak( + "Your decision journal is empty — nothing to review yet." + ) + return + + selected = self._select_decision(all_decisions, hint) + if selected is None: + selected = all_decisions[0] + + summary = selected["summary"] + category = selected.get("category", "other") + alternatives = selected.get("alternatives", []) + decision_type = selected.get("decision_type", "made") + captured_at = selected.get("captured_at", "") + outcome_sentiment = selected.get("outcome_sentiment") + + # Build spoken read-back + date_str = "" + if captured_at: + try: + dt = datetime.fromisoformat(captured_at) + date_str = f" — captured {dt.strftime('%B %d')}" + except Exception: + pass + + alt_str = "" + if alternatives: + alt_str = f" You were weighing: {', '.join(alternatives)}." + + outcome_str = "" + if outcome_sentiment and outcome_sentiment != "too_soon": + outcome_str = f" Outcome: {outcome_sentiment}." + elif selected.get("status") == "pending_outcome": + outcome_str = " No outcome recorded yet." + + msg = f"{summary}{date_str}. Category: {category}.{alt_str}{outcome_str}" + + if decision_type == "deliberating": + msg += " You were still weighing this when I captured it — has anything changed?" + + await self.capability_worker.speak(msg) + + # Offer next action + if selected.get("status") == "pending_outcome": + await self.capability_worker.speak( + "Want to record an outcome, reflect on this decision, or hear another? Say a number, a keyword, or stop." + ) + else: + await self.capability_worker.speak( + "Want to reflect on this, hear another decision, or stop?" + ) + + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + + r = reply.lower() + if any(kw in r for kw in ("outcome", "how did", "record", "turned out")): + await self._handle_outcome(data, selected["summary"]) + elif any(kw in r for kw in ("reflect", "why", "think")): + await self._handle_reflect(data, selected["summary"]) + else: + await self._handle_explore(data, reply, depth + 1) + + async def _handle_outcome(self, data: dict, hint: str = ""): + # Reload fresh data + data = await self._load_journal() + pending = self._pending_outcome(data) + + # Also include history items without outcomes if user asks about an old one + all_resolvable = pending + [ + d for d in data.get("history", []) + if d.get("status") == "pending_outcome" + ] + + if not all_resolvable: + await self.capability_worker.speak( + "All your decisions already have outcomes recorded — your journal is up to date!" + ) + return + + # Try to match from hint directly + selected = None + if hint: + selected = self._select_decision(all_resolvable, hint) + + # If hint didn't resolve to a specific match or was empty, ask user to pick + if selected is None or not hint: + if len(all_resolvable) == 1: + selected = all_resolvable[0] + else: + count = min(len(all_resolvable), 5) + top = all_resolvable[:count] + list_str = self._build_decision_list(top) + await self.capability_worker.speak( + f"Which decision? Here are the ones without outcomes: {list_str}. " + "Say a number or a keyword." + ) + reply = await self.capability_worker.user_response() + if self._is_exit(reply): + return + selected = self._select_decision(top, reply) or top[0] + + summary = selected["summary"] + await self.capability_worker.speak( + f"How did '{summary}' turn out — good call, bad call, mixed, or still too soon to tell?" + ) + + sentiment_reply = await self.capability_worker.user_response() + if self._is_exit(sentiment_reply): + return + + sentiment = self._infer_outcome_sentiment(sentiment_reply) + + reflection = None + if sentiment != "too_soon": + reflection_reply = await self.capability_worker.run_io_loop( + "One sentence — what did you learn from it?" + ) + if reflection_reply and not self._is_exit(reflection_reply): + reflection = reflection_reply.strip() + + # Update the decision in data + now_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + updated = False + for item in data["decisions"]: + if item["id"] == selected["id"]: + item["outcome_sentiment"] = sentiment + item["outcome_at"] = now_str if sentiment != "too_soon" else None + item["reflection"] = reflection + item["status"] = "outcome_recorded" if sentiment != "too_soon" else "pending_outcome" + if sentiment != "too_soon": + data["stats"]["total_with_outcomes"] = ( + data["stats"].get("total_with_outcomes", 0) + 1 + ) + updated = True + break + + # Also check history + if not updated: + for item in data.get("history", []): + if item["id"] == selected["id"]: + item["outcome_sentiment"] = sentiment + item["outcome_at"] = now_str if sentiment != "too_soon" else None + item["reflection"] = reflection + item["status"] = "outcome_recorded" if sentiment != "too_soon" else "pending_outcome" + if sentiment != "too_soon": + data["stats"]["total_with_outcomes"] = ( + data["stats"].get("total_with_outcomes", 0) + 1 + ) + break + + await self._save_journal(data) + + if sentiment == "too_soon": + await self.capability_worker.speak( + f"Got it — I'll check back on '{summary}' in a couple of weeks." + ) + else: + sentiment_word = {"positive": "a good call", "negative": "a tough lesson", "mixed": "a mixed one"}.get(sentiment, "noted") + await self.capability_worker.speak( + f"Got it — marked '{summary}' as {sentiment_word}." + + (f" Saved your reflection too." if reflection else "") + ) + + async def _handle_reflect(self, data: dict, hint: str = "", depth: int = 0): + # Reload fresh data + data = await self._load_journal() + + if depth >= REFLECT_DEPTH_CAP: + await self.capability_worker.speak( + "Great reflection session — that's a good place to pause. " + "Say 'decision journal' anytime to continue." + ) + return + + all_decisions = data.get("decisions", []) + data.get("history", []) + if not all_decisions: + await self.capability_worker.speak("Your decision journal is empty — nothing to reflect on yet.") + return + + selected = self._select_decision(all_decisions, hint) or all_decisions[0] + summary = selected["summary"] + category = selected.get("category", "other") + alternatives = selected.get("alternatives", []) + outcome_sentiment = selected.get("outcome_sentiment", "not yet recorded") + decision_type = selected.get("decision_type", "made") + + # Generate one thoughtful reflective question + reflect_prompt = ( + f"User's decision: {summary}\n" + f"Category: {category}\n" + f"Alternatives considered: {', '.join(alternatives) if alternatives else 'none mentioned'}\n" + f"Outcome: {outcome_sentiment}\n" + f"Decision type: {decision_type}\n\n" + "Ask one thoughtful, open-ended reflective question to help the user examine this decision deeper. " + "One sentence only. Curious, not judgmental. Spoken aloud. " + "Focus on what they learned, how they felt, or what they'd do differently." + ) + + try: + question = self.capability_worker.text_to_text_response(reflect_prompt) + except Exception: + question = f"Looking back at your decision to {summary} — what stands out most to you now?" + + await self.capability_worker.speak(question) + user_answer = await self.capability_worker.user_response() + if self._is_exit(user_answer): + return + + # Generate a brief insight acknowledging their answer + insight_prompt = ( + f"The user decided: {summary}\n" + f"When asked: {question}\n" + f"They said: {user_answer}\n\n" + "Respond with a 2-3 sentence empathetic insight that acknowledges their answer " + "and adds one useful perspective. Spoken aloud. No bullet points." + ) + + try: + insight = self.capability_worker.text_to_text_response(insight_prompt) + except Exception: + insight = "That's a really honest reflection — the awareness itself is valuable." + + await self.capability_worker.speak(insight) + + # Offer to go deeper (up to REFLECT_DEPTH_CAP) + if depth + 1 < REFLECT_DEPTH_CAP: + await self.capability_worker.speak("Want to go deeper on this, or are you good?") + reply = await self.capability_worker.user_response() + if not self._is_exit(reply): + await self._handle_reflect(data, selected["summary"], depth + 1) + + async def _handle_pattern(self, data: dict): + all_decisions = data.get("decisions", []) + data.get("history", []) + + if len(all_decisions) < PATTERN_MIN_DECISIONS: + remaining = PATTERN_MIN_DECISIONS - len(all_decisions) + await self.capability_worker.speak( + f"I need a few more decisions before I can spot meaningful patterns — " + f"just {remaining} more. Keep talking and I'll keep capturing." + ) + return + + # Build formatted decision list for LLM + lines = [] + for d in all_decisions[-30:]: # Cap at 30 most recent for context length + outcome = d.get("outcome_sentiment") or "no outcome yet" + lines.append( + f"- [{d.get('category', 'other')}] {d['summary']} " + f"(type: {d.get('decision_type', 'made')}, outcome: {outcome})" + ) + formatted = "\n".join(lines) + + pattern_prompt = ( + f"Here are decisions made by this user (most recent last):\n{formatted}\n\n" + "Identify 2-3 genuine, specific patterns in HOW this person makes decisions. " + "Be honest and concrete. Good examples:\n" + "- 'You tend to act quickly on career decisions but overthink financial ones'\n" + "- 'Most of your health decisions happen right after big life events'\n" + "- 'You almost always land on the simpler option after deliberating too long'\n\n" + "3-4 sentences. No bullet points. Spoken aloud. Insightful, not generic." + ) + + try: + insight = self.capability_worker.text_to_text_response(pattern_prompt) + except Exception: + insight = ( + "Based on your decisions so far, you seem to act decisively when it matters most. " + "Keep capturing and I'll give you a richer picture over time." + ) + + await self.capability_worker.speak(insight) + + # Inject into personality + try: + self.capability_worker.update_personality_agent_prompt( + f"[Decision pattern insight]: {insight[:200]}" + ) + except Exception: + pass + + async def _handle_add(self, data: dict, trigger_text: str): + # Try to extract decision from trigger text + topic = "" + t = trigger_text.lower() + add_markers = [ + "add a decision", "log a decision", "record a decision", "save a decision", + "add", "log", "record", "save", + ] + for marker in add_markers: + if marker in t: + idx = t.index(marker) + len(marker) + after = trigger_text[idx:].strip().lstrip(",:- ").strip() + if len(after.split()) >= 3: + topic = after[:200] + break + + if not topic: + reply = await self.capability_worker.run_io_loop( + "What decision did you make, or what are you deciding between?" + ) + if self._is_exit(reply) or not reply: + return + topic = reply.strip()[:200] + + if not topic: + await self.capability_worker.speak("I didn't catch a decision. No worries!") + return + + # Dedup check + topic_words = set(re.findall(r'\b[a-z]+\b', topic.lower())) + for existing in data.get("decisions", []): + existing_words = set(re.findall(r'\b[a-z]+\b', existing.get("summary", "").lower())) + if existing_words: + overlap = len(topic_words & existing_words) / max(len(topic_words), len(existing_words), 1) + if overlap >= 0.60: + await self.capability_worker.speak( + "That decision is already in your journal! Say 'decision journal' to review it." + ) + return + + # Ask for category + cat_reply = await self.capability_worker.run_io_loop( + "What category — career, financial, health, relationship, or personal?" + ) + category = "other" + if cat_reply and not self._is_exit(cat_reply): + cat_reply_lower = cat_reply.lower() + for cat in ["career", "financial", "health", "relationship", "personal"]: + if cat in cat_reply_lower: + category = cat + break + if category == "other": + category = _infer_category(topic) + + # Ask for alternatives (optional) + alt_reply = await self.capability_worker.run_io_loop( + "Any alternatives you were weighing? Say them or say skip." + ) + alternatives = [] + if alt_reply and not self._is_exit(alt_reply) and "skip" not in alt_reply.lower(): + # Parse comma or "or" separated alternatives + alts = re.split(r',|\bor\b', alt_reply, flags=re.IGNORECASE) + alternatives = [a.strip() for a in alts if len(a.strip()) > 2][:4] + + # Infer decision type + decision_type = "deliberating" if any( + kw in topic.lower() for kw in ["torn", "deciding", "leaning", "weighing", "between"] + ) else "made" + + entry = { + "id": str(int(datetime.now().timestamp() * 1000)), + "summary": topic, + "raw": trigger_text[:500], + "decision_type": decision_type, + "category": category, + "alternatives": alternatives, + "significance": "medium", + "captured_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + "date": datetime.now().strftime("%Y-%m-%d"), + "outcome": None, + "outcome_at": None, + "outcome_sentiment": None, + "reflection": None, + "status": "pending_outcome", + } + data.setdefault("decisions", []).append(entry) + data["stats"]["total_captured"] = data["stats"].get("total_captured", 0) + 1 + + # Overflow + if len(data["decisions"]) > 50: + resolved = [d for d in data["decisions"] if d.get("status") == "outcome_recorded"] + oldest = min( + resolved if resolved else data["decisions"], + key=lambda x: x.get("captured_at", "") + ) + data["decisions"].remove(oldest) + data.setdefault("history", []).append(oldest) + + if len(data.get("history", [])) > 100: + data["history"] = data["history"][-100:] + + await self._save_journal(data) + + pending_count = len(self._pending_outcome(data)) + await self.capability_worker.speak( + f"Logged! You now have {pending_count} " + f"{'decision' if pending_count == 1 else 'decisions'} without outcomes. " + "Say 'decision journal' anytime to reflect or record outcomes." + ) + + async def _handle_history(self, data: dict): + resolved_queue = self._outcome_recorded(data) + resolved_history = [d for d in data.get("history", []) if d.get("outcome_sentiment")] + all_resolved = resolved_queue + resolved_history + + if not all_resolved: + await self.capability_worker.speak( + "No outcomes recorded yet — start by saying 'record an outcome' after reviewing a decision." + ) + return + + # Sort by outcome_at descending, fallback to captured_at + all_resolved_sorted = sorted( + all_resolved, + key=lambda x: x.get("outcome_at") or x.get("captured_at", ""), + reverse=True, + ) + recent = all_resolved_sorted[:5] + + parts = [] + for i, item in enumerate(recent): + sentiment_label = { + "positive": "good call", + "negative": "tough lesson", + "mixed": "mixed", + }.get(item.get("outcome_sentiment", ""), "recorded") + parts.append(f"{i + 1}. {item['summary']} — {sentiment_label}") + + await self.capability_worker.speak( + f"Here are your {len(recent)} most recently resolved decisions. {'. '.join(parts)}." + ) + + total_captured = data["stats"].get("total_captured", 0) + total_with_outcomes = data["stats"].get("total_with_outcomes", 0) + if total_captured > 0: + await self.capability_worker.speak( + f"You've recorded outcomes for {total_with_outcomes} of {total_captured} " + f"{'decision' if total_captured == 1 else 'decisions'} total." + ) + + async def _handle_stats(self, data: dict): + total_captured = data["stats"].get("total_captured", 0) + total_with_outcomes = data["stats"].get("total_with_outcomes", 0) + pending = self._pending_outcome(data) + pending_count = len(pending) + + if total_captured == 0: + await self.capability_worker.speak( + "No decisions captured yet — just talk naturally and I'll start building your journal." + ) + return + + # Category breakdown + all_decisions = data.get("decisions", []) + cat_counts: dict = {} + for d in all_decisions: + cat = d.get("category", "other") + cat_counts[cat] = cat_counts.get(cat, 0) + 1 + + cat_str = ", ".join( + f"{count} {cat}" for cat, count in sorted(cat_counts.items(), key=lambda x: -x[1]) + ) + + # Oldest pending outcome + oldest_str = "" + if pending: + oldest = min(pending, key=lambda x: x.get("date", "")) + oldest_str = f" Oldest unresolved: {oldest['summary']}." + + await self.capability_worker.speak( + f"You have {total_captured} {'decision' if total_captured == 1 else 'decisions'} total — " + f"{cat_str}. " + f"Outcomes recorded for {total_with_outcomes}, " + f"{pending_count} still pending.{oldest_str}" + ) + + async def _handle_clear_all(self, data: dict): + total = len(data.get("decisions", [])) + if total == 0: + await self.capability_worker.speak("Your decision journal is already empty!") + return + + confirmed = await self.capability_worker.run_confirmation_loop( + f"Clear all {total} {'decision' if total == 1 else 'decisions'} including unresolved ones?" + ) + if confirmed: + data["decisions"] = [] + await self._save_journal(data) + await self.capability_worker.speak( + "Done — decision journal cleared. Start fresh anytime!" + ) + else: + await self.capability_worker.speak("No problem, keeping everything.") + + async def _handle_toggle(self, intent: str, data: dict): + turn_on = intent == "TOGGLE_NOTIFY_ON" + data.setdefault("settings", {})["notify_on_capture"] = turn_on + await self._save_journal(data) + if turn_on: + await self.capability_worker.speak( + "Got it — I'll let you know each time I capture a decision." + ) + else: + await self.capability_worker.speak( + "Done — I'll capture decisions silently. Say 'decision journal' anytime to review them." + ) + + # ------------------------------------------------------------------ + # Main run loop + # ------------------------------------------------------------------ + + async def _run(self): + try: + await self.capability_worker.wait_for_complete_transcription() + + # Get trigger utterance from history + trigger_text = "" + try: + history = self.capability_worker.get_full_message_history() + history = history or [] + user_msgs = [m for m in history if m.get("role") == "user"] + if user_msgs: + trigger_text = user_msgs[-1].get("content", "") or "" + if not isinstance(trigger_text, str): + trigger_text = "" + except Exception: + trigger_text = "" + + intent = self._classify_intent(trigger_text) + self.worker.editor_logging_handler.info( + f"[DecisionJournal] Intent: {intent} | Trigger: {trigger_text[:80]}" + ) + + data = await self._load_journal() + + if intent == "LIST": + await self._handle_list(data) + elif intent == "OUTCOME": + await self._handle_outcome(data, trigger_text) + elif intent == "REFLECT": + await self._handle_reflect(data, trigger_text) + elif intent == "PATTERN": + await self._handle_pattern(data) + elif intent == "ADD": + await self._handle_add(data, trigger_text) + elif intent == "HISTORY": + await self._handle_history(data) + elif intent == "STATS": + await self._handle_stats(data) + elif intent == "CLEAR_ALL": + await self._handle_clear_all(data) + elif intent == "TOGGLE_NOTIFY_ON": + await self._handle_toggle("TOGGLE_NOTIFY_ON", data) + elif intent == "TOGGLE_NOTIFY_OFF": + await self._handle_toggle("TOGGLE_NOTIFY_OFF", data) + else: + await self.capability_worker.speak( + "I can review your decisions, record outcomes, reflect on a choice, " + "or show your decision patterns. What would you like?" + ) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[DecisionJournal] Skill error: {e}") + try: + await self.capability_worker.speak( + "Something went wrong. Try asking again in a moment." + ) + except Exception: + pass + finally: + self.capability_worker.resume_normal_flow() + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self._run()) From ee5b473308c11a0ba65f047e08c3438036987589 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Apr 2026 10:06:55 +0000 Subject: [PATCH 3/9] style: auto-format Python files with autoflake + autopep8 --- community/decision-journal/background.py | 2 +- community/decision-journal/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/community/decision-journal/background.py b/community/decision-journal/background.py index c74ffefd..f99e3971 100644 --- a/community/decision-journal/background.py +++ b/community/decision-journal/background.py @@ -92,7 +92,7 @@ class DecisionJournalBackground(MatchingCapability): background_daemon_mode: bool = False # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} # ------------------------------------------------------------------ # File I/O diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py index 7d678b97..b77bc047 100644 --- a/community/decision-journal/main.py +++ b/community/decision-journal/main.py @@ -100,7 +100,7 @@ class DecisionJournalCapability(MatchingCapability): capability_worker: CapabilityWorker = None # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} # ------------------------------------------------------------------ # Hotword matching From 05fd1da7b52680976be4d80f60af10ae449b4f62 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 26 Apr 2026 15:09:20 +0500 Subject: [PATCH 4/9] Fix f-string missing placeholder on line 515 --- community/decision-journal/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py index b77bc047..70ed76c6 100644 --- a/community/decision-journal/main.py +++ b/community/decision-journal/main.py @@ -512,7 +512,7 @@ async def _handle_outcome(self, data: dict, hint: str = ""): sentiment_word = {"positive": "a good call", "negative": "a tough lesson", "mixed": "a mixed one"}.get(sentiment, "noted") await self.capability_worker.speak( f"Got it — marked '{summary}' as {sentiment_word}." - + (f" Saved your reflection too." if reflection else "") + + (" Saved your reflection too." if reflection else "") ) async def _handle_reflect(self, data: dict, hint: str = "", depth: int = 0): From 9a67afd96de9b8737bda48963de5824451868347 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 26 Apr 2026 15:34:23 +0500 Subject: [PATCH 5/9] Fix trigger text capture and daemon flag reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use wait_for_complete_transcription() return value as trigger_text in main.py so intent classification sees the actual current utterance — get_full_message_history() does not include the current turn at call time. Move nudge_checked_today and briefed_today flag-setting into finally blocks in background.py so they are always set even when an exception occurs, preventing the stale-nudge and daily-briefing blocks from re-firing on every poll after an error. --- community/decision-journal/background.py | 10 +++++++--- community/decision-journal/main.py | 19 ++++++------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/community/decision-journal/background.py b/community/decision-journal/background.py index f99e3971..615ce411 100644 --- a/community/decision-journal/background.py +++ b/community/decision-journal/background.py @@ -397,11 +397,13 @@ async def watch_loop(self): ) data_fresh["settings"]["last_nudge_date"] = today await self._save_journal(data_fresh) - s["nudge_checked_today"] = True except Exception as e: self.worker.editor_logging_handler.error( f"[DecisionJournal] Stale nudge error: {e}" ) + finally: + # Always mark checked — even on exception — to prevent re-firing every poll + s["nudge_checked_today"] = True # ------------------------------------------------------------------ # Daily briefing (new day + enough pending-outcome decisions) @@ -423,12 +425,14 @@ async def watch_loop(self): ) data_fresh["settings"]["last_brief_date"] = today await self._save_journal(data_fresh) - s["last_brief_date"] = today - s["briefed_today"] = True except Exception as e: self.worker.editor_logging_handler.error( f"[DecisionJournal] Daily brief error: {e}" ) + finally: + # Always mark briefed — even on exception — to prevent re-firing every poll + s["last_brief_date"] = today + s["briefed_today"] = True # ------------------------------------------------------------------ # Periodic settings re-sync (~5 minutes) diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py index 70ed76c6..2d41f715 100644 --- a/community/decision-journal/main.py +++ b/community/decision-journal/main.py @@ -855,19 +855,12 @@ async def _handle_toggle(self, intent: str, data: dict): async def _run(self): try: - await self.capability_worker.wait_for_complete_transcription() - - # Get trigger utterance from history - trigger_text = "" - try: - history = self.capability_worker.get_full_message_history() - history = history or [] - user_msgs = [m for m in history if m.get("role") == "user"] - if user_msgs: - trigger_text = user_msgs[-1].get("content", "") or "" - if not isinstance(trigger_text, str): - trigger_text = "" - except Exception: + # wait_for_complete_transcription returns the full transcribed utterance — + # this is the only reliable way to get the current message since + # get_full_message_history() does NOT include the current turn until + # after resume_normal_flow() is called. + trigger_text = await self.capability_worker.wait_for_complete_transcription() + if not trigger_text or not isinstance(trigger_text, str): trigger_text = "" intent = self._classify_intent(trigger_text) From d73d7c44b4f2edd47692cfefd111a58403bd6802 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 26 Apr 2026 15:42:04 +0500 Subject: [PATCH 6/9] Fix five pre-launch bugs across main.py and background.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Sentiment substring collision — _infer_outcome_sentiment now uses whole-word regex for all single-word keywords so "know" no longer matches "no" → negative, "yesterday" no longer matches "yes" → positive, etc. Multi-word phrases checked first as they are unambiguous. 2. Outcome auto-selection — _handle_outcome now requires genuine keyword overlap between hint and a decision summary before auto-picking. Generic trigger text ("record an outcome") no longer silently selects the first pending item when multiple decisions exist; the user is asked to pick instead. 3. ADD intent miss — _classify_intent now also catches "add/log + decision" so "add to my decision journal" routes correctly to ADD instead of falling to LIST. 4. Category inference gap — _handle_add now calls _infer_category(topic) as the default before asking the user, so the best available category is always set even when the user exits the category question. 5. Daily flags never reset — background.py tracks s["current_day"] and resets nudge_checked_today and briefed_today whenever the calendar date changes, ensuring stale-nudge and daily-briefing fire correctly on day 2+ of long sessions. --- community/decision-journal/background.py | 11 +++- community/decision-journal/main.py | 83 +++++++++++++++++++----- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/community/decision-journal/background.py b/community/decision-journal/background.py index 615ce411..dd80552f 100644 --- a/community/decision-journal/background.py +++ b/community/decision-journal/background.py @@ -68,6 +68,7 @@ def _new_state() -> dict: "briefed_today": False, "nudge_checked_today": False, "personality_injected_count": 0, + "current_day": "", # tracks the calendar date so daily flags reset at midnight } @@ -371,9 +372,17 @@ async def watch_loop(self): self.worker.editor_logging_handler.error(f"[DecisionJournal] Loop error: {e}") # ------------------------------------------------------------------ - # Stale-outcome nudge (once per day — HIGH significance, 14+ days old) + # Day-change reset — runs for long sessions that cross midnight # ------------------------------------------------------------------ today = datetime.now().strftime("%Y-%m-%d") + if today != s["current_day"]: + s["current_day"] = today + s["nudge_checked_today"] = False + s["briefed_today"] = False + + # ------------------------------------------------------------------ + # Stale-outcome nudge (once per day — HIGH significance, 14+ days old) + # ------------------------------------------------------------------ if not s["nudge_checked_today"]: try: stale_cutoff = ( diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py index 2d41f715..4a8c1294 100644 --- a/community/decision-journal/main.py +++ b/community/decision-journal/main.py @@ -151,8 +151,9 @@ def _classify_intent(self, text: str) -> str: # History if any(kw in t for kw in ("history", "past decisions", "already resolved", "already answered")): return "HISTORY" - # Add manually - if any(kw in t for kw in ("add a decision", "log a decision", "record a decision", "save a decision")): + # Add manually — catches both "add a decision" and "add to my decision journal" + if any(kw in t for kw in ("add a decision", "log a decision", "record a decision", "save a decision")) \ + or (("add" in t or "log" in t) and "decision" in t): return "ADD" # Stats if any(kw in t for kw in ("stats", "how many decisions", "decision count")): @@ -243,15 +244,54 @@ def _build_grouped_list(self, decisions: list) -> str: return result def _infer_outcome_sentiment(self, reply: str) -> str: - """Map user's natural language reply to a sentiment label.""" + """Map user's natural language reply to a sentiment label. + + Uses whole-word matching for short keywords to avoid substring traps: + "no" matches "know", "yes" matches "yesterday", etc. + Multi-word phrases ("too soon", "not yet", "worth it") are checked first + since they are unambiguous and more specific than single words. + """ t = reply.lower() - for keyword, sentiment in SENTIMENT_MAP.items(): - if keyword in t: + + # Multi-word phrases first — unambiguous, checked before single words + MULTI_WORD = [ + ("too soon", "too_soon"), + ("not yet", "too_soon"), + ("worth it", "positive"), + ("so-so", "mixed"), + ] + for phrase, sentiment in MULTI_WORD: + if phrase in t: + return sentiment + + # Single-word keywords — whole-word only via regex to prevent "know"→"no", etc. + SINGLE_WORD = [ + (r"\bgood\b", "positive"), + (r"\bgreat\b", "positive"), + (r"\bright\b", "positive"), + (r"\byes\b", "positive"), + (r"\bworked\b", "positive"), + (r"\bpositive\b", "positive"), + (r"\bbad\b", "negative"), + (r"\bwrong\b", "negative"), + (r"\bmistake\b", "negative"), + (r"\bregret\b", "negative"), + (r"\bnegative\b", "negative"), + (r"\bno\b", "negative"), + (r"\bmixed\b", "mixed"), + (r"\bokay\b", "mixed"), + (r"\balright\b", "mixed"), + (r"\bstill\b", "too_soon"), + (r"\bearly\b", "too_soon"), + ] + for pattern, sentiment in SINGLE_WORD: + if re.search(pattern, t): return sentiment - # If reply sounds positive in spirit + + # Spirit-level fallbacks if any(w in t for w in ("love", "happy", "glad", "thrilled", "perfect", "amazing")): return "positive" - if any(w in t for w in ("hate", "regret", "awful", "terrible", "horrible")): + if any(w in t for w in ("hate", "awful", "terrible", "horrible")): return "negative" return "mixed" @@ -431,13 +471,25 @@ async def _handle_outcome(self, data: dict, hint: str = ""): ) return - # Try to match from hint directly + # Only auto-select when the hint has genuine keyword overlap with a decision + # summary. Generic trigger phrases like "record an outcome" have no overlap + # with decision summaries and must not silently pick the first item. selected = None if hint: - selected = self._select_decision(all_resolvable, hint) - - # If hint didn't resolve to a specific match or was empty, ask user to pick - if selected is None or not hint: + hint_words = set(re.findall(r'\b[a-z]+\b', hint.lower())) + best_overlap, best_item = 0, None + for item in all_resolvable: + item_words = set(re.findall(r'\b[a-z]+\b', item.get("summary", "").lower())) + if item_words and hint_words: + ov = len(hint_words & item_words) / max(len(hint_words), len(item_words), 1) + if ov > best_overlap: + best_overlap, best_item = ov, item + # Require at least one meaningful overlapping word + if best_overlap > 0: + selected = best_item + + # No meaningful match — ask user to pick (or auto-pick when only one option) + if selected is None: if len(all_resolvable) == 1: selected = all_resolvable[0] else: @@ -672,19 +724,18 @@ async def _handle_add(self, data: dict, trigger_text: str): ) return - # Ask for category + # Ask for category — always start from keyword inference so even if user + # exits the question we still get the best available category label. + category = _infer_category(topic) cat_reply = await self.capability_worker.run_io_loop( "What category — career, financial, health, relationship, or personal?" ) - category = "other" if cat_reply and not self._is_exit(cat_reply): cat_reply_lower = cat_reply.lower() for cat in ["career", "financial", "health", "relationship", "personal"]: if cat in cat_reply_lower: category = cat break - if category == "other": - category = _infer_category(topic) # Ask for alternatives (optional) alt_reply = await self.capability_worker.run_io_loop( From 2e1b9703ba4cbd55e006856a775ec254e9b082e0 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 26 Apr 2026 16:17:48 +0500 Subject: [PATCH 7/9] Fix CLEAR_ALL intent not matching 'clear my decisions' The check required both a clear keyword AND 'all' in the text. 'clear my decisions' is a registered hotword but contains no 'all', so it fell through to LIST. Widened the match to also fire when the text contains 'decision' or 'journal', which covers all natural phrasings users would say within this skill. --- community/decision-journal/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py index 4a8c1294..63aa5063 100644 --- a/community/decision-journal/main.py +++ b/community/decision-journal/main.py @@ -131,8 +131,10 @@ def _outcome_recorded(self, data: dict) -> list: def _classify_intent(self, text: str) -> str: t = text.lower() - # Destructive first - if any(kw in t for kw in ("clear", "delete", "wipe", "remove")) and "all" in t: + # Destructive first — "clear my decisions" is a registered hotword so + # requiring "all" is too strict; match on any decision/journal/all context. + if any(kw in t for kw in ("clear", "delete", "wipe")) and \ + ("all" in t or "decision" in t or "journal" in t): return "CLEAR_ALL" # Toggle notifications if any(kw in t for kw in ("stop notif", "no notif", "disable notif")): From 3eb7254a807dec7f39815c5c8dcc7c270276e7e8 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 26 Apr 2026 16:32:23 +0500 Subject: [PATCH 8/9] Fix three UX flow bugs caught on live editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug A — 'add a decision' mid-list routed to explore: _handle_list reply router had no ADD branch so any unrecognised reply fell to _handle_explore. Added ADD routing before the else. Bug B — 'hear another' replayed the same decision: _handle_explore had no awareness of what was just shown so asking for 'another' when only one decision exists looped forever on the same item. Added exclude_id parameter: when the user asks for 'another/different/next', the current decision is excluded from candidates. If nothing remains, a friendly message is spoken and the flow exits cleanly. Bug C — 'clear my decisions' consumed as outcome/reflection answer: Any hotword said while user_response() was waiting was treated as an answer to the open question. Added _intercept_redirect() helper that checks every user_response() reply for clear/add intent before the answer is processed. Also guards the run_io_loop reflection step so a redirect command is never saved as reflection text. --- community/decision-journal/main.py | 68 +++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py index 63aa5063..0817baa1 100644 --- a/community/decision-journal/main.py +++ b/community/decision-journal/main.py @@ -123,6 +123,28 @@ def _is_exit(self, text: str) -> bool: return True return bool(_EXIT_PATTERN.search(text)) + async def _intercept_redirect(self, reply: str, data: dict) -> bool: + """ + Called after every user_response() to catch mid-flow intent changes. + If the user says something like 'clear my decisions' while the outcome + question is still open, this handles it and returns True so the caller + can break out of the current flow cleanly. + """ + t = reply.lower().strip() + # Clear intent + if any(kw in t for kw in ("clear", "delete", "wipe")) and \ + ("all" in t or "decision" in t or "journal" in t): + fresh = await self._load_journal() + await self._handle_clear_all(fresh) + return True + # Add intent + if any(kw in t for kw in ("add a decision", "log a decision")) or \ + (("add" in t or "log" in t) and "decision" in t): + fresh = await self._load_journal() + await self._handle_add(fresh, reply) + return True + return False + def _pending_outcome(self, data: dict) -> list: return [d for d in data.get("decisions", []) if d.get("status") == "pending_outcome"] @@ -368,6 +390,8 @@ async def _handle_list(self, data: dict): reply = await self.capability_worker.user_response() if self._is_exit(reply): return + if await self._intercept_redirect(reply, data): + return # Route reply r = reply.lower() @@ -377,10 +401,13 @@ async def _handle_list(self, data: dict): await self._handle_pattern(data) elif any(kw in r for kw in ("reflect", "why did", "think through")): await self._handle_reflect(data, reply) + elif any(kw in r for kw in ("add a decision", "log a decision", "add", "log", "new decision")) \ + and any(kw in r for kw in ("decision", "add", "log")): + await self._handle_add(data, reply) else: await self._handle_explore(data, reply) - async def _handle_explore(self, data: dict, hint: str = "", depth: int = 0): + async def _handle_explore(self, data: dict, hint: str = "", depth: int = 0, exclude_id: str = ""): # Reload fresh data to catch any daemon additions data = await self._load_journal() @@ -397,9 +424,22 @@ async def _handle_explore(self, data: dict, hint: str = "", depth: int = 0): ) return - selected = self._select_decision(all_decisions, hint) + # When user asks for "another" and we know what was just shown, exclude it. + # If nothing remains after exclusion, tell the user gracefully. + hint_lower = hint.lower() + candidates = all_decisions + if exclude_id and any(kw in hint_lower for kw in ("another", "different", "next", "other")): + candidates = [d for d in all_decisions if d.get("id") != exclude_id] + if not candidates: + await self.capability_worker.speak( + "That's the only decision in your journal right now — " + "keep talking and I'll capture more as you make them." + ) + return + + selected = self._select_decision(candidates, hint) if selected is None: - selected = all_decisions[0] + selected = candidates[0] summary = selected["summary"] category = selected.get("category", "other") @@ -447,14 +487,20 @@ async def _handle_explore(self, data: dict, hint: str = "", depth: int = 0): reply = await self.capability_worker.user_response() if self._is_exit(reply): return + if await self._intercept_redirect(reply, data): + return r = reply.lower() if any(kw in r for kw in ("outcome", "how did", "record", "turned out")): await self._handle_outcome(data, selected["summary"]) elif any(kw in r for kw in ("reflect", "why", "think")): await self._handle_reflect(data, selected["summary"]) + elif any(kw in r for kw in ("add a decision", "log a decision")) or \ + (("add" in r or "log" in r) and "decision" in r): + await self._handle_add(data, reply) else: - await self._handle_explore(data, reply, depth + 1) + # Pass current decision's ID so "hear another" correctly skips it + await self._handle_explore(data, reply, depth + 1, exclude_id=selected.get("id", "")) async def _handle_outcome(self, data: dict, hint: str = ""): # Reload fresh data @@ -515,6 +561,10 @@ async def _handle_outcome(self, data: dict, hint: str = ""): sentiment_reply = await self.capability_worker.user_response() if self._is_exit(sentiment_reply): return + # If user says something like "clear my decisions" while this prompt is open, + # handle it as a redirect and exit the outcome flow cleanly. + if await self._intercept_redirect(sentiment_reply, data): + return sentiment = self._infer_outcome_sentiment(sentiment_reply) @@ -524,7 +574,15 @@ async def _handle_outcome(self, data: dict, hint: str = ""): "One sentence — what did you learn from it?" ) if reflection_reply and not self._is_exit(reflection_reply): - reflection = reflection_reply.strip() + # Don't save a redirect command as a reflection — it means the user + # changed their mind mid-flow (e.g. said "clear my decisions" here). + r = reflection_reply.lower() + is_redirect = ( + any(kw in r for kw in ("clear", "delete", "wipe")) and + ("all" in r or "decision" in r or "journal" in r) + ) + if not is_redirect: + reflection = reflection_reply.strip() # Update the decision in data now_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") From be8ae29be3d9de47d922b5d9275ecb96650dfc3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Apr 2026 11:32:37 +0000 Subject: [PATCH 9/9] style: auto-format Python files with autoflake + autopep8 --- community/decision-journal/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/community/decision-journal/main.py b/community/decision-journal/main.py index 0817baa1..689be326 100644 --- a/community/decision-journal/main.py +++ b/community/decision-journal/main.py @@ -578,8 +578,8 @@ async def _handle_outcome(self, data: dict, hint: str = ""): # changed their mind mid-flow (e.g. said "clear my decisions" here). r = reflection_reply.lower() is_redirect = ( - any(kw in r for kw in ("clear", "delete", "wipe")) and - ("all" in r or "decision" in r or "journal" in r) + any(kw in r for kw in ("clear", "delete", "wipe")) + and ("all" in r or "decision" in r or "journal" in r) ) if not is_redirect: reflection = reflection_reply.strip()