From 80d380231e9ffa2a8a33a2c47db0e127d26dcd6b Mon Sep 17 00:00:00 2001 From: jarvisxyz Date: Mon, 6 Apr 2026 13:25:55 -0400 Subject: [PATCH 1/4] feat(gateway): Enable Slack thread replies without explicit @mentions (#1) When a user replies in a Slack thread where the bot has an active conversation session, the bot now processes the message even without an explicit @mention. This improves UX for ongoing threaded discussions. Changes: - Added set_session_store() to BasePlatformAdapter for adapters to check active sessions - Modified SlackAdapter to detect thread replies and check if a session exists for that thread before requiring @mentions - Updated GatewayRunner to inject the session store into adapters - Added comprehensive tests for the new behavior Fixes: Thread replies without @jarvis are now processed if there is an active session, matching user expectations for conversation flow Co-authored-by: eizus --- gateway/platforms/base.py | 10 +++ gateway/platforms/slack.py | 85 +++++++++++++++++++++- gateway/run.py | 2 + tests/gateway/test_slack.py | 141 ++++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5261aceea532..cf2ff2e93ab1 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -519,6 +519,16 @@ def set_message_handler(self, handler: MessageHandler) -> None: """ self._message_handler = handler + def set_session_store(self, session_store: Any) -> None: + """ + Set the session store for checking active sessions. + + Used by adapters that need to check if a thread/conversation + has an active session before processing messages (e.g., Slack + thread replies without explicit mentions). + """ + self._session_store = session_store + @abstractmethod async def connect(self) -> bool: """ diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 2e7bbee739ba..31058dddb276 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -763,11 +763,28 @@ async def _handle_slack_message(self, event: dict) -> None: else: thread_ts = event.get("thread_ts") or ts # ts fallback for channels - # In channels, only respond if bot is mentioned + # In channels, only respond if bot is mentioned OR if this is a + # reply in a thread where the bot has an active session. bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) - if not is_dm and bot_uid: - if f"<@{bot_uid}>" not in text: + is_mentioned = bot_uid and f"<@{bot_uid}>" in text + + if not is_dm and bot_uid and not is_mentioned: + # Check if this is a thread reply (thread_ts exists and differs from ts) + event_thread_ts = event.get("thread_ts") + is_thread_reply = event_thread_ts and event_thread_ts != ts + + if is_thread_reply and self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, + ): + # Allow thread replies without mention if there's an active session + pass + else: + # Not a thread reply or no active session - ignore return + + if is_mentioned: # Strip the bot mention from the text text = text.replace(f"<@{bot_uid}>", "").strip() @@ -933,6 +950,68 @@ async def _handle_slash_command(self, command: dict) -> None: await self.handle_message(event) + def _has_active_session_for_thread( + self, + channel_id: str, + thread_ts: str, + user_id: str, + ) -> bool: + """Check if there's an active session for a thread. + + Used to determine if thread replies without @mentions should be + processed (they should if there's an active session). + + Args: + channel_id: The Slack channel ID + thread_ts: The thread timestamp (parent message ts) + user_id: The user ID of the sender + + Returns: + True if there's an active session for this thread + """ + session_store = getattr(self, "_session_store", None) + if not session_store: + return False + + try: + # Build a SessionSource for this thread + from gateway.session import SessionSource + from gateway.config import Platform + + source = SessionSource( + platform=Platform.SLACK, + chat_id=channel_id, + chat_type="group", + user_id=user_id, + thread_id=thread_ts, + ) + + # Generate the session key using the same logic as SessionStore + # This mirrors the logic in build_session_key for group sessions + key_parts = ["agent:main", "slack", "group", channel_id, thread_ts] + + # Include user_id if group_sessions_per_user is enabled + # We check the session store config if available + group_sessions_per_user = getattr( + session_store, "config", {} + ) + if hasattr(group_sessions_per_user, "group_sessions_per_user"): + group_sessions_per_user = group_sessions_per_user.group_sessions_per_user + else: + group_sessions_per_user = True # Default + + if group_sessions_per_user and user_id: + key_parts.append(str(user_id)) + + session_key = ":".join(key_parts) + + # Check if the session exists in the store + session_store._ensure_loaded() + return session_key in session_store._entries + except Exception: + # If anything goes wrong, default to False (require mention) + return False + async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: """Download a Slack file using the bot token for auth, with retry.""" import asyncio diff --git a/gateway/run.py b/gateway/run.py index f909a2c738c5..fd91698e716a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1127,6 +1127,7 @@ async def start(self) -> bool: # Set up message + fatal error handlers adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) + adapter.set_session_store(self.session_store) # Try to connect logger.info("Connecting to %s...", platform.value) @@ -1424,6 +1425,7 @@ async def _platform_reconnect_watcher(self) -> None: adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) + adapter.set_session_store(self.session_store) success = await adapter.connect() if success: diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 81f8077ad6b3..89b447183443 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -699,6 +699,147 @@ async def test_reactions_in_message_flow(self, adapter): assert remove_calls[0].kwargs["name"] == "eyes" +# --------------------------------------------------------------------------- +# TestThreadReplyHandling +# --------------------------------------------------------------------------- + + +class TestThreadReplyHandling: + """Test thread reply processing without explicit bot mentions.""" + + @pytest.fixture() + def mock_session_store(self): + """Create a mock session store with entries dict.""" + store = MagicMock() + store._entries = {} + store._ensure_loaded = MagicMock() + store.config = MagicMock() + store.config.group_sessions_per_user = True + return store + + @pytest.fixture() + def adapter_with_session_store(self, mock_session_store): + """Create an adapter with a mock session store attached.""" + config = PlatformConfig(enabled=True, token="***") + a = SlackAdapter(config) + a._app = MagicMock() + a._app.client = AsyncMock() + a._bot_user_id = "U_BOT" + a._team_bot_user_ids = {"T_TEAM": "U_BOT"} + a._running = True + a.handle_message = AsyncMock() + a.set_session_store(mock_session_store) + return a + + @pytest.mark.asyncio + async def test_thread_reply_without_mention_no_session_ignored( + self, adapter_with_session_store, mock_session_store + ): + """Thread replies without mention should be ignored if no active session.""" + mock_session_store._entries = {} # No active sessions + + event = { + "text": "Just replying in the thread", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", # Different from ts - this is a reply + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_thread_reply_without_mention_with_session_processed( + self, adapter_with_session_store, mock_session_store + ): + """Thread replies without mention should be processed if there's an active session.""" + # Simulate an active session for this thread + session_key = "agent:main:slack:group:C123:123.000:U_USER" + mock_session_store._entries = {session_key: MagicMock()} + + event = { + "text": "Follow-up question", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", # Reply in thread 123.000 + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_called_once() + + # Verify the text is passed through unchanged (no mention stripping needed) + msg_event = adapter_with_session_store.handle_message.call_args[0][0] + assert msg_event.text == "Follow-up question" + + @pytest.mark.asyncio + async def test_thread_reply_with_mention_strips_bot_id( + self, adapter_with_session_store, mock_session_store + ): + """Thread replies with @mention should still strip the bot ID.""" + # Even with a session, mentions should be stripped + session_key = "agent:main:slack:group:C123:123.000:U_USER" + mock_session_store._entries = {session_key: MagicMock()} + + event = { + "text": "<@U_BOT> thanks for the help", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_called_once() + + msg_event = adapter_with_session_store.handle_message.call_args[0][0] + assert "<@U_BOT>" not in msg_event.text + assert msg_event.text == "thanks for the help" + + @pytest.mark.asyncio + async def test_top_level_message_requires_mention_even_with_session( + self, adapter_with_session_store, mock_session_store + ): + """Top-level channel messages should require mention even if session exists.""" + # Session exists but this is a top-level message (no thread_ts) + session_key = "agent:main:slack:group:C123:123.000:U_USER" + mock_session_store._entries = {session_key: MagicMock()} + + event = { + "text": "New question without mention", + "user": "U_USER", + "channel": "C123", + "ts": "456.789", + # No thread_ts - this is a top-level message + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_no_session_store_ignores_thread_replies( + self, adapter + ): + """If no session store is attached, thread replies without mention should be ignored.""" + # adapter fixture has no session store attached + event = { + "text": "Thread reply without mention", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter._handle_slack_message(event) + adapter.handle_message.assert_not_called() + + # --------------------------------------------------------------------------- # TestUserNameResolution # --------------------------------------------------------------------------- From 7ae5b7858456450fb5aab6136b73262ecfc687c5 Mon Sep 17 00:00:00 2001 From: jarvisxyz Date: Mon, 6 Apr 2026 13:26:28 -0400 Subject: [PATCH 2/4] fix(gateway): Apply markdown-to-mrkdwn conversion in edit_message (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit_message method was sending raw content directly to Slack's chat_update API without converting standard markdown to Slack's mrkdwn format. This caused broken formatting and malformed URLs (e.g., trailing ** from bold syntax became part of clickable links → 404 errors). The send() method already calls format_message() to handle this conversion, but edit_message() was bypassing it. This change ensures edited messages receive the same markdown → mrkdwn transformation as new messages. Closes: PR #5558 formatting issue where links had trailing markdown syntax. Co-authored-by: eizus --- gateway/platforms/slack.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 31058dddb276..384f379d768a 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -276,10 +276,13 @@ async def edit_message( if not self._app: return SendResult(success=False, error="Not connected") try: + # Convert standard markdown → Slack mrkdwn + formatted = self.format_message(content) + await self._get_client(chat_id).chat_update( channel=chat_id, ts=message_id, - text=content, + text=formatted, ) return SendResult(success=True, message_id=message_id) except Exception as e: # pragma: no cover - defensive logging From de7c21ba0844bd3c404d3a887a8b5c815708f82b Mon Sep 17 00:00:00 2001 From: eizus Date: Mon, 6 Apr 2026 13:28:43 -0400 Subject: [PATCH 3/4] fix: Update pricing tiers to match app (Basic $1, Starter $10, Agency $299) - Replace 4-tier structure (Free/Writer/Pro/Agency) with 3-tier - Basic: $1/mo = 1 site, 10 quotes, 1 social - Starter: $10/mo = 3 sites, 1k quotes, 10 social - Agency: $299/mo = 100 sites, 100k quotes, 500 social - Update blog post pricing section - Adjust grid layout from 4 to 3 columns --- .DS_Store | Bin 0 -> 12292 bytes cdn | 1 + .../business-cards/business-card-dark-qr.pdf | Bin 0 -> 9147 bytes .../business-cards/business-card-light-qr.pdf | Bin 0 -> 9228 bytes scripts/.DS_Store | Bin 0 -> 6148 bytes ...b_membership_fees_FAQ_20260406_115305.json | 1 + work/depreciation_analysis.py | 401 ++++++++++++++++++ work/depreciation_data.json | 129 ++++++ work/ontario_cost_analysis.txt | 90 ++++ work/research.py | 77 ++++ work/research2.py | 86 ++++ work/research3.py | 32 ++ work/research4.py | 40 ++ work/research5.py | 37 ++ work/research6.py | 32 ++ work/research_results.json | 153 +++++++ 16 files changed, 1079 insertions(+) create mode 100644 .DS_Store create mode 160000 cdn create mode 100644 design/business-cards/business-card-dark-qr.pdf create mode 100644 design/business-cards/business-card-light-qr.pdf create mode 100644 scripts/.DS_Store create mode 100644 text_Burlington_Rifle_Revolver_Club_membership_fees_FAQ_20260406_115305.json create mode 100644 work/depreciation_analysis.py create mode 100644 work/depreciation_data.json create mode 100644 work/ontario_cost_analysis.txt create mode 100644 work/research.py create mode 100644 work/research2.py create mode 100644 work/research3.py create mode 100644 work/research4.py create mode 100644 work/research5.py create mode 100644 work/research6.py create mode 100644 work/research_results.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6f8274eede3795aee7bdf515a62292afa43487e3 GIT binary patch literal 12292 zcmeHNTWB0r82%^OY_4r{v!M^#tu(e38d|D^QY+gS^+Cj7q=BL~$!5C^$!6DNQyZl; zB36*%jrt^L#Nvz8if#HJTB@alN(-$wtoA`g@I}D~5fu@?|D3t}XD*vfA4Kd-m^qXA zzyJK-`OcYhnU>UI1l5*eKS#p#wbgZ zyAqs}+p>!kVuhZIgu({?(mQ`L3g!c%y*^AuhkxW_s` zJxkeX+D>P6s$AitT#;&5%U)tw+8xhR3`d%_(^;Krce1oQS$1U0UZGejI{Z=!PF7l{ z@ikT;Rv@Ur03I1@Z@JFidDqMHD?eP=5yXtRYw;*daqs4SZw;T~$O(ImKgIh4uFbmV zK53Kje;9D%yT18?7dy;u#I$hH9O6J^;2pgyi`1y5~DvKUQ@YKzRyAqYOUl zSVp>m_VVtI(r=1(gHz5{vI}Vre;c9k1nr@M!Ic)qmgX(XQQY_N`9IF6ax5XfRR7v? zFv_n-l?Gq=Gc-=4MoDj3Hi43c;$)dFtf~3BS=UVj)~lhuo-4;1%3uwi>s7mH1y640 z8I|r6N;0UBF%c`CuPG1*^_Si3uHuxrfVaC@> zT6Eee>U13a$`p2%M{zf9vZt1d?=S8l%dq2A+i&x_?_MO1)Vp#oSsq$B%jKz8EtB`g zxyR%lGH14d`nhnF39ICDc-Je=`(hxx z+7CyB#IIHHr)_Oa4#~cHaPT1{k2^|hcm1iyhXBgs5{)wW)Lud`M4Lk2IfR6T#=Cw9;2LrFeWzYj8>WMu1~Iojp7OD?y*_o`DzMjnz$ z6h`u(L+>(=8Z{T0@@qmn+w-PXa%g+-sxy8Gf5{*mVZ_hOas1_~Pngi(n9wKiSjIED zB2J^07>uJVUUIy$ZO+kY5g)HbRqA7uL-S%C3i$K)W!-BR01#Hm@u8E3j<&zlF&tsU z-9liajC-l6xO#YH_WgaH~ zH1lr+kN0t$Y=}ACt2GXl*-;ulH6JbTs;khH|mBXjQFi7 z1iy}~T*hb@oyEwC#=lzMCFImj}c+wRuc5_!m4z4lxTJ#@YEK0Ej5 zTzcRB+r2#VH|(-3+u!b?K0WXBVZ1tt!k9b;N&H{Km4x5^@%R6`qksRWK=CD3AXec2 zseqN}?eFbD*{q22@wfIA&Ow}vIfIiA+$$Gc7{dRk&+$mr=Xj)>_&FcAPuuCNPVofC Xzy3pjc>FIa;p6c?VjgmRjQ{@tF*(Al literal 0 HcmV?d00001 diff --git a/cdn b/cdn new file mode 160000 index 000000000000..1e20e7bfae82 --- /dev/null +++ b/cdn @@ -0,0 +1 @@ +Subproject commit 1e20e7bfae82bc977297e9349ec7cf85b0d84d72 diff --git a/design/business-cards/business-card-dark-qr.pdf b/design/business-cards/business-card-dark-qr.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bbf4c005f08bf8749e463dcd425364c95428a041 GIT binary patch literal 9147 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 j1Yp2(jMb5bKPA9Wd|)CmcS) literal 0 HcmV?d00001 diff --git a/text_Burlington_Rifle_Revolver_Club_membership_fees_FAQ_20260406_115305.json b/text_Burlington_Rifle_Revolver_Club_membership_fees_FAQ_20260406_115305.json new file mode 100644 index 000000000000..0637a088a01e --- /dev/null +++ b/text_Burlington_Rifle_Revolver_Club_membership_fees_FAQ_20260406_115305.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/work/depreciation_analysis.py b/work/depreciation_analysis.py new file mode 100644 index 000000000000..aa095f3b670d --- /dev/null +++ b/work/depreciation_analysis.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Ontario Vehicle Depreciation Analysis - 2025/2026 +Based on real market data gathered from: +- VW dealership pricing (Milton VW, VW of Newmarket) +- Toyota media releases and dealer sites +- Kelley Blue Book depreciation data +- VMR Canada used car values +- Motor Illustrated, Driving.ca, EmptyTank automotive journalism +""" + +import json + +# ============================================================ +# ONTARIO TAX / REBATE STRUCTURE +# ============================================================ +HST = 0.13 # 13% Ontario HST + +# iZEV (federal plug-in EV incentive) +# As of 2025-2026, the iZEV program offers: +# - $5,000 for vehicles with MSRP under $55,000 +# - $2,500 for vehicles with MSRP between $55,000-$65,000 +# Note: As of late 2024/2025, the government announced the iZEV program +# funding is winding down, but for analysis we assume it's still available +# RAV4 Prime PHEP qualifies as a long-range PHEP +IZEV_REBATE_PHEP = 2500 # PHEPs get $2,500 (lower amount since 2024 changes) +# Note: The 2026 RAV4 Plug-in Hybrid starts at $48,750 which is under $55k +# but PHEPs receive $2,500 not $5,000 + +# Ontario used car PST rule: +# Ontario eliminated the separate PST on used cars in 2010 - only HST (13%) +# applies to used cars from private sales if you pay fair market value +# Dealerships charge HST on used car sales +# For private sales, buyer pays HST on the USED CAR VALUE (not purchase price) +# if below book value - but commonly dealers and private sales both = 13% HST + +# ============================================================ +# VEHICLE 1: Used 2022 VW Tiguan (80k km, listed $25,000) +# ============================================================ +# +# DATA SOURCES: +# - KBB: 2022 Tiguan depreciated 38% over 3 years, avg resale ~$15,100 +# - VMR Canada: 2022 Tiguan Highline retail ~$27,825, projected: +# 1yr: $24,800, 3yr: $16,325, 5yr: $11,000 +# - Kijiji listing: $30,907 OBO with 48,837 km (higher trim, lower km) +# - CarWise Canada: $24,799 with 102,885 km +# - CarGurus: "Save $5,950 on 2022 Tiguan near you" (average deal) +# +# Given: 2022 Tiguan, 80k km, listed at $25,000 +# This is reasonable market price for the condition/mileage +# Since already 3-4 years old, depreciation curve is SHALLLOWER + +TIGUAN_2022 = { + "vehicle": "Used 2022 VW Tiguan (80,000 km)", + "current_listed_price": 25000, # as-stated listing price + "description": "Already depreciated significantly; used car shallower curve", + + # Depreciation rates for USED 3-4 year old VW Tiguan + # Based on KBB data: ~$2,500-3,500/year for used Tiguans + # VMR Canada curve for Highline: $27,825 -> $16,325 (3yr) -> $11,000 (5yr) + # That's about 12-15% annual depreciation on remaining value for used + + "depreciation": { + # From today (vehicle is ~4 years old, 80k km) + # Year 3 from now: vehicle will be ~7 years old, ~130k-145k km + # Year 5 from now: vehicle will be ~9 years old, ~180k km + # Year 7 from now: vehicle will be ~11 years old, ~230k km + # Year 10 from now: vehicle will be ~14 years old, ~300k km + + "year_3": { + "year": 2029, + "vehicle_age": 7, + "est_km": 140000, + "depreciated_value": 15500, + "pct_of_current": 0.62, + "note": "~38% depreciation over 3 more years from used starting point" + }, + "year_5": { + "year": 2031, + "vehicle_age": 9, + "est_km": 180000, + "depreciated_value": 11800, + "pct_of_current": 0.47, + "note": "~53% depreciation over 5 years from used point" + }, + "year_7": { + "year": 2033, + "vehicle_age": 11, + "est_km": 230000, + "depreciated_value": 8200, + "pct_of_current": 0.33, + "note": "~67% depreciation - high mileage affects value" + }, + "year_10": { + "year": 2036, + "vehicle_age": 14, + "est_km": 300000, + "depreciated_value": 4500, + "pct_of_current": 0.18, + "note": "Floor value approaching scrap/residual" + } + } +} + +# ============================================================ +# VEHICLE 2: New 2026 VW Tiguan Highline Turbo R-Line +# ============================================================ +# +# DATA from VW of Newmarket (real dealer site): +# MSRP: $48,995 +# Freight & PDI: $2,200 +# OMVIC Fee: $22 +# Admin Fee: $500 (typical dealer) +# Total before tax: ~$51,717 +# +# KBB 2022 Tiguan depreciation data shows: +# 3-year depreciation: ~38% from new +# New car depreciation year 1: ~20-25% off MSRP +# Year 2-3: another 10-12% each +# Year 5: typically retains ~40-45% of original +# +# VW brand depreciates faster than Toyota - typical compact SUV pattern + +TIGUAN_2026_NEW = { + "vehicle": "New 2026 VW Tiguan Highline Turbo R-Line 4MOTION", + "msrp": 48995, + "freight_pdi": 2200, + "omvic_fee": 22, + "admin_fee": 500, + "total_before_tax": 51717, + "total_with_hst": round(51717 * 1.13, 2), + "description": "New car, steeper initial depreciation", + + "depreciation": { + # VW Tiguan new car depreciation (based on KBB historical data) + # Year 1: ~22-25% off new + # Year 2: ~10-12% additional + # Year 3: ~8-10% additional (= ~38% total from KBB data) + # Year 5: ~55% total remaining value + # Year 7: ~38-42% remaining + # Year 10: ~22-25% remaining + + "year_3": { + "year": 2029, + "vehicle_age": 3, + "est_km": 60000, + "remaining_pct": 0.62, + "depreciated_value": round(51717 * 0.62), + "note": "~38% lost in first 3 years (matches KBB data)" + }, + "year_5": { + "year": 2031, + "vehicle_age": 5, + "est_km": 100000, + "remaining_pct": 0.47, + "depreciated_value": round(51717 * 0.47), + "note": "53% lost by year 5 - VW depreciation curve" + }, + "year_7": { + "year": 2033, + "vehicle_age": 7, + "est_km": 140000, + "remaining_pct": 0.36, + "depreciated_value": round(51717 * 0.36), + "note": "64% lost - entering high-mileage range" + }, + "year_10": { + "year": 2036, + "vehicle_age": 10, + "est_km": 200000, + "remaining_pct": 0.23, + "depreciated_value": round(51717 * 0.23), + "note": "77% lost - significant age and mileage" + } + } +} + +# ============================================================ +# VEHICLE 3: New 2026 Toyota RAV4 Prime XSE +# ============================================================ +# +# DATA SOURCES: +# - ErinPark Toyota (March 31, 2026): "2026 Toyota RAV4 Plug-in Hybrid: Four Grades +# Now Available Starting at $48,750" +# - 2026 RAV4 Hybrid XSE (non-PHEP): MSRP $50,900; XSE Tech: $52,450 +# - The PHEP XSE will be priced HIGHER than the hybrid XSE +# - 2024 (old gen) RAV4 Prime XSE MSRP was ~$49,450 + $1,800 freight = ~$51,250 +# - New generation PHEP: Likely $53,000-$55,000 for XSE trim +# (PHEP starts at $48,750 for base, XSE would be $3,000-$4,000 higher) +# Estimated: XSE MSRP ~$52,500-$53,500 +# Freight & PDI: ~$1,800-1,950 (Toyota's typical PDI) +# +# iZEV REBATE: $2,500 for PHEPs (federal) +# Ontario has NO provincial EV rebate (OZIP ended in 2018) +# +# KBB 2024 RAV4 Prime data: +# - 28% depreciation over 3 years +# - Retains 72% of value after 3 years +# - In top 10-25% for depreciation among SUVs +# - 2024 RAV4 Prime: new ~$45,085 -> current resale $32,400 +# +# RAV4 Prime has EXCEPTIONAL resale value due to limited supply and high demand + +RAV4_PRIME_2026 = { + "vehicle": "New 2026 Toyota RAV4 Plug-in Hybrid XSE", + "estimated_xse_msrp": 52500, # estimated based on hybrid XSE ($50,900) + PHEP premium + "freight_pdi": 1895, # Toyota typical PDI + "total_before_tax": 54395, + "total_with_hst": round(54395 * 1.13, 2), + "izev_rebate": 2500, + "total_with_hst_minus_rebate": round(54395 * 1.13 - 2500, 2), + "description": "Best-in-class resale value; PHEP demand exceeds supply", + + "depreciation": { + # RAV4 Prime depreciation - EXCEPTIONAL retention + # KBB 2024 data: 28% over 3 years (72% retained) + # This was an older model with very high dealer markups + # New model with better supply but still high demand: + # + # Year 1: ~12-15% (Toyota depreciation, but Prime holds well) + # Year 3: ~28% (KBB data for Prime) + # Year 5: ~38-42% lost + # Year 7: ~50-55% lost + # Year 10: ~62-65% lost + + "year_3": { + "year": 2029, + "vehicle_age": 3, + "est_km": 60000, + "remaining_pct": 0.72, + "depreciated_value": round(54395 * 0.72), + "note": "Only 28% lost - best-in-class retention (KBB data)" + }, + "year_5": { + "year": 2031, + "vehicle_age": 5, + "est_km": 100000, + "remaining_pct": 0.60, + "depreciated_value": round(54395 * 0.60), + "note": "40% lost - still above average" + }, + "year_7": { + "year": 2033, + "vehicle_age": 7, + "est_km": 140000, + "remaining_pct": 0.48, + "depreciated_value": round(54395 * 0.48), + "note": "52% lost - Toyota reputation sustains value" + }, + "year_10": { + "year": 2036, + "vehicle_age": 10, + "est_km": 200000, + "remaining_pct": 0.36, + "depreciated_value": round(54395 * 0.36), + "note": "64% lost - still good for a 10-year-old SUV" + } + } +} + +# ============================================================ +# SUMMARY TABLE +# ============================================================ + +print("=" * 80) +print("ONTARIO VEHICLE DEPRECIATION ANALYSIS - 2025/2026") +print("=" * 80) +print() + +# Vehicle 1 +print("VEHICLE 1: Used 2022 VW Tiguan (80,000 km) - Listed $25,000") +print("-" * 80) +v1 = TIGUAN_2022 +base1 = v1["current_listed_price"] +print(f" Current listed price: ${base1:>10,}") +print(f" + HST (13%): ${base1 * HST:>10,.2f}") +print(f" TOTAL ON-THE-ROAD (estimated): ${base1 * (1+HST):>10,.2f}") +print() +for key, val in v1["depreciation"].items(): + print(f" {val['year']} ({key.replace('_',' ')}): ${val['depreciated_value']:>10,} ({val['pct_of_current']*100:.0f}% of current value) | ~{val['est_km']:,} km") +print() + +# Vehicle 2 +print("VEHICLE 2: New 2026 VW Tiguan Highline Turbo R-Line") +print("-" * 80) +v2 = TIGUAN_2026_NEW +print(f" MSRP: ${v2['msrp']:>10,}") +print(f" Freight & PDI: ${v2['freight_pdi']:>10,}") +print(f" OMVIC Fee: ${v2['omvic_fee']:>10,}") +print(f" Admin Fee (avg): ${v2['admin_fee']:>10,}") +print(f" ─────────────────────────────────────────────────") +print(f" SUBTOTAL (before tax): ${v2['total_before_tax']:>10,}") +print(f" + HST (13%): ${v2['total_with_hst'] - v2['total_before_tax']:>10,.2f}") +print(f" TOTAL ON-THE-ROAD: ${v2['total_with_hst']:>10,.2f}") +print() +print(" RESIDUAL VALUES (from pre-tax subtotal):") +for key, val in v2["depreciation"].items(): + print(f" {val['year']} ({key.replace('_',' ')}): ${val['depreciated_value']:>10,} ({val['remaining_pct']*100:.0f}% of original) | ~{val['est_km']:,} km") +print() + +# Vehicle 3 +print("VEHICLE 3: New 2026 Toyota RAV4 Plug-in Hybrid XSE") +print("-" * 80) +v3 = RAV4_PRIME_2026 +print(f" Estimated XSE MSRP (est.): ${v3['estimated_xse_msrp']:>10,}") +print(f" Freight & PDI: ${v3['freight_pdi']:>10,}") +print(f" ─────────────────────────────────────────────────") +print(f" SUBTOTAL (before tax): ${v3['total_before_tax']:>10,}") +print(f" + HST (13%): ${v3['total_with_hst'] - v3['total_before_tax']:>10,.2f}") +print(f" TOTAL ON-THE-ROAD (before rebate): ${v3['total_with_hst']:>10,.2f}") +print(f" - iZEV Federal Rebate: -${v3['izev_rebate']:>9,}") +print(f" NET ON-THE-ROAD (after rebate): ${v3['total_with_hst_minus_rebate']:>10,.2f}") +print() +print(" RESIDUAL VALUES (from pre-tax subtotal):") +for key, val in v3["depreciation"].items(): + print(f" {val['year']} ({key.replace('_',' ')}): ${val['depreciated_value']:>10,} ({val['remaining_pct']*100:.0f}% of original) | ~{val['est_km']:,} km") +print() + +# ============================================================ +# COMPARISON TABLE +# ============================================================ +print("=" * 80) +print("COMPARISON SUMMARY TABLE") +print("=" * 80) +print() +print(f"{'':>38} {'VW 2022 Used':>15} {'VW 2026 New':>15} {'RAV4 PHEP':>15}") +print(f"{'':>38} {'(80k km)':>15} {'Highline':>15} {'XSE':>15}") +print("-" * 80) + +headers = ["", "25k used Tig", "51.7k new Tg", "54.4k RAV4 PE"] +print(f"{'Purchase price (pre-tax)':>38} ${base1:>13,} ${v2['total_before_tax']:>13,} ${v3['total_before_tax']:>13,}") + +# Year 3 +print() +print(f" Year 3 (2029) Residual:", end="") +for v, name in [(TIGUAN_2022, 'T1'), (TIGUAN_2026_NEW, 'T2'), (RAV4_PRIME_2026, 'T3')]: + val = v['depreciation']['year_3']['depreciated_value'] + print(f" ${val:>13,}", end="") +print() + +# Year 5 +print(f" Year 5 (2031) Residual:", end="") +for v in [TIGUAN_2022, TIGUAN_2026_NEW, RAV4_PRIME_2026]: + val = v['depreciation']['year_5']['depreciated_value'] + print(f" ${val:>13,}", end="") +print() + +# Year 7 +print(f" Year 7 (2033) Residual:", end="") +for v in [TIGUAN_2022, TIGUAN_2026_NEW, RAV4_PRIME_2026]: + val = v['depreciation']['year_7']['depreciated_value'] + print(f" ${val:>13,}", end="") +print() + +# Year 10 +print(f" Year 10 (2036) Residual:", end="") +for v in [TIGUAN_2022, TIGUAN_2026_NEW, RAV4_PRIME_2026]: + val = v['depreciation']['year_10']['depreciated_value'] + print(f" ${val:>13,}", end="") +print() + +print() +print("=" * 80) +print("NOTES AND ASSUMPTIONS") +print("=" * 80) +print(""" +1. TAXES: Ontario HST is 13% on new and used car sales (dealerships). + Private-party used sales: Buyer pays HST on the higher of purchase price + or wholesale value - effectively ~13% in most cases. + +2. iZEV REBATE: Federal rebate of $2,500 for PHEPs under $55,000 MSRP. + The 2026 RAV4 Plug-in Hybrid XSE is estimated at ~$52,500 + $1,895 freight. + Ontario has no provincial EV rebate (OZIP ended 2018). + +3. VW TIGUAN DEPRECIATION: Based on Kelley Blue Book data showing 2022 Tiguan + depreciated 38% over 3 years. New Tiguans lose ~22% in year 1 alone. + German luxury/premium brands depreciate faster than Japanese brands. + +4. RAV4 PRIME DEPRECIATION: Based on KBB 2024 RAV4 Prime data showing only + 28% depreciation over 3 years (72% retained). Exceptional for the segment. + Toyota's reputation for reliability + PHEP demand + limited supply = + best-in-class resale value. + +5. KM ASSUMPTIONS: 18,000-20,000 km/year average driving for new vehicles. + Used Tiguan: +12,000-15,000 km/year (higher mileage used car pattern). + +6. RAV4 PRIME MSRP NOTE: The 2026 RAV4 Plug-in Hybrid starting price of + $48,750 (announced March 31, 2026) is for the base grade. The XSE trim + is estimated at ~$52,500 based on the $3,000-$4,000 premium over base + that the 2026 Hybrid XSE carries over the Hybrid LE. +""") + +# Save to JSON +with open('/Users/jarvis/.hermes/hermes-agent/work/depreciation_data.json', 'w') as f: + json.dump({ + "tiguan_2022_used": TIGUAN_2022, + "tiguan_2026_new": TIGUAN_2026_NEW, + "rav4_prime_2026": RAV4_PRIME_2026 + }, f, indent=2) + +print("Data saved to depreciation_data.json") diff --git a/work/depreciation_data.json b/work/depreciation_data.json new file mode 100644 index 000000000000..73195ec5eb92 --- /dev/null +++ b/work/depreciation_data.json @@ -0,0 +1,129 @@ +{ + "tiguan_2022_used": { + "vehicle": "Used 2022 VW Tiguan (80,000 km)", + "current_listed_price": 25000, + "description": "Already depreciated significantly; used car shallower curve", + "depreciation": { + "year_3": { + "year": 2029, + "vehicle_age": 7, + "est_km": 140000, + "depreciated_value": 15500, + "pct_of_current": 0.62, + "note": "~38% depreciation over 3 more years from used starting point" + }, + "year_5": { + "year": 2031, + "vehicle_age": 9, + "est_km": 180000, + "depreciated_value": 11800, + "pct_of_current": 0.47, + "note": "~53% depreciation over 5 years from used point" + }, + "year_7": { + "year": 2033, + "vehicle_age": 11, + "est_km": 230000, + "depreciated_value": 8200, + "pct_of_current": 0.33, + "note": "~67% depreciation - high mileage affects value" + }, + "year_10": { + "year": 2036, + "vehicle_age": 14, + "est_km": 300000, + "depreciated_value": 4500, + "pct_of_current": 0.18, + "note": "Floor value approaching scrap/residual" + } + } + }, + "tiguan_2026_new": { + "vehicle": "New 2026 VW Tiguan Highline Turbo R-Line 4MOTION", + "msrp": 48995, + "freight_pdi": 2200, + "omvic_fee": 22, + "admin_fee": 500, + "total_before_tax": 51717, + "total_with_hst": 58440.21, + "description": "New car, steeper initial depreciation", + "depreciation": { + "year_3": { + "year": 2029, + "vehicle_age": 3, + "est_km": 60000, + "remaining_pct": 0.62, + "depreciated_value": 32065, + "note": "~38% lost in first 3 years (matches KBB data)" + }, + "year_5": { + "year": 2031, + "vehicle_age": 5, + "est_km": 100000, + "remaining_pct": 0.47, + "depreciated_value": 24307, + "note": "53% lost by year 5 - VW depreciation curve" + }, + "year_7": { + "year": 2033, + "vehicle_age": 7, + "est_km": 140000, + "remaining_pct": 0.36, + "depreciated_value": 18618, + "note": "64% lost - entering high-mileage range" + }, + "year_10": { + "year": 2036, + "vehicle_age": 10, + "est_km": 200000, + "remaining_pct": 0.23, + "depreciated_value": 11895, + "note": "77% lost - significant age and mileage" + } + } + }, + "rav4_prime_2026": { + "vehicle": "New 2026 Toyota RAV4 Plug-in Hybrid XSE", + "estimated_xse_msrp": 52500, + "freight_pdi": 1895, + "total_before_tax": 54395, + "total_with_hst": 61466.35, + "izev_rebate": 2500, + "total_with_hst_minus_rebate": 58966.35, + "description": "Best-in-class resale value; PHEP demand exceeds supply", + "depreciation": { + "year_3": { + "year": 2029, + "vehicle_age": 3, + "est_km": 60000, + "remaining_pct": 0.72, + "depreciated_value": 39164, + "note": "Only 28% lost - best-in-class retention (KBB data)" + }, + "year_5": { + "year": 2031, + "vehicle_age": 5, + "est_km": 100000, + "remaining_pct": 0.6, + "depreciated_value": 32637, + "note": "40% lost - still above average" + }, + "year_7": { + "year": 2033, + "vehicle_age": 7, + "est_km": 140000, + "remaining_pct": 0.48, + "depreciated_value": 26110, + "note": "52% lost - Toyota reputation sustains value" + }, + "year_10": { + "year": 2036, + "vehicle_age": 10, + "est_km": 200000, + "remaining_pct": 0.36, + "depreciated_value": 19582, + "note": "64% lost - still good for a 10-year-old SUV" + } + } + } +} \ No newline at end of file diff --git a/work/ontario_cost_analysis.txt b/work/ontario_cost_analysis.txt new file mode 100644 index 000000000000..2b51c2824e49 --- /dev/null +++ b/work/ontario_cost_analysis.txt @@ -0,0 +1,90 @@ + +======================================================================= +ONTARIO ANNUAL COST COMPARISON — 16,000 km/year +Kitchener–Waterloo driver +======================================================================= + +PRICING INPUTS: +──────────────── + Gas – Regular 87 octane (Toyota): $1.559 /L (Ontario avg Mar 2026) + Gas – Premium 91+ octane (VW): $1.829 /L (+$0.27/L premium) + Electricity – Off-peak TOU: $0.087/kWh (OEB Nov 2025, 7pm–7am) + +======================================================================= + 2022 TIGUAN 2026 TIGUAN 2026 RAV4 PRIME + USED GAS NEW GAS XSE PHEV +────────────────────────────────────────────────────────────────────── +Fuel – Gasoline $2,926 $2,780 $567 +Fuel – Electricity – – $217 +FUEL TOTAL $2,926 $2,780 $784 + +Insurance $1,650 $2,150 $2,250 +(mo. equiv.) $138 $179 $188 + +Maintenance $1,200 $850 $550 + +Registration $100 $100 $100 +────────────────────────────────────────────────────────────────────── +TOTAL ANNUAL COST $5,876 $5,880 $3,684 +(per km) $0.368 $0.368 $0.230 +────────────────────────────────────────────────────────────────────── + +RAV4 PRIME FUEL BREAKDOWN: +────────────────────────── + Electric driving: 10,400 km/yr (65% of total) + Gas driving: 5,600 km/yr (35% of total) + Electric cost: $217/yr (2,496 kWh × $0.087) + Gas cost: $567/yr (364 L × $1.559) + Combined fuel: $784/yr → only $4.90 per 100 km + + Vs. gas-only SUV: Saves ~$2,100/year on fuel alone + Vs. gas-only SUV: Saves ~$2,200/year total ($37/week) + +======================================================================= +KEY TAKEAWAYS: +────────────── +1. The 2022 used Tiguan and 2026 new Tiguan have nearly + identical annual operating costs ($5,876 vs $5,880). + The new car's slightly lower fuel + maintenance is + offset by much higher insurance. + +2. The RAV4 Prime PHEV saves ~$2,200/year in operating + costs vs either Tiguan ($3,684 vs $5,876–5,880). + +3. At 65% electric split, a home-charged RAV4 Prime driver + would only fill gas ~6–8 times per year, using only + ~364 L annually vs 1,600 L (used Tiguan) or 1,520 L + (new Tiguan). + +4. The RAV4 Prime's higher purchase price (~$62K with HST + vs $25K–$58K) is partially offset by fuel + maintenance + savings of ~$2,500–3,400/year. Break-even on the premium + (vs used Tiguan) in ~15 years on TCO alone, or ~7 years + vs the new Tiguan if fuel + maintenance savings are + compared to the new-car insurance + fuel premium. + +5. Insurance is a significant variable – the above uses + Kitchener-Waterloo averages (10–15% below GTA). + Actual quotes depend heavily on driving record, age, + and bundling. + +======================================================================= +COST ASSUMPTIONS & SOURCES: +───────────────────────────── +• Gas: GlobalPetrolPrices Ontario avg March 2026 + GasWizard.ca price history, Ontario.ca + Range in recent months: $1.40–$1.91/L +• Premium: +$0.27/L over regular (Ontario.ca price data) +• Electricity: OEB TOU effective Nov 1 2025 + Off-peak: $0.087/kWh (7pm–7am weekdays, all day weekends) + Also available: ULO overnight $0.087/kWh +• Maintenance: VW 10-yr/160K km = $8,185 (CarEdge) + Toyota RAV4 Prime 10-yr = $6,200 (CarEdge) +• Insurance: Ontario avg $2,120/yr, Kitchener-Waterloo + 10–15% below GTA. Vehicle value & type adjustment. + Source: ThinkInsure.ca, Ratehub.ca, MyChoice.ca +• RAV4 Prime electric efficiency: 24 kWh/100km real-world +• Toyota MSRP from Toyota Canada media (Jan 2026): + RAV4 Prime XSE: $52,408 MSRP +• VW 2026 Tiguan MSRP: ~$48,995 (Highline R-Line) +======================================================================= diff --git a/work/research.py b/work/research.py new file mode 100644 index 000000000000..9c245d73cc84 --- /dev/null +++ b/work/research.py @@ -0,0 +1,77 @@ +import re +import urllib.request +import json + +def fetch_and_search(query, label): + url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query) + req = urllib.request.Request(url, headers={ + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + }) + try: + html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8', errors='replace') + except Exception as e: + print(f" ERROR fetching: {e}") + return + + # Extract results + results = [] + # Pattern 1: data-u attribute + blocks = html.split('