utim-cli 1.47.1__tar.gz → 1.47.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {utim_cli-1.47.1 → utim_cli-1.47.2}/CHANGELOG.md +11 -0
  2. {utim_cli-1.47.1/utim_cli.egg-info → utim_cli-1.47.2}/PKG-INFO +1 -1
  3. {utim_cli-1.47.1 → utim_cli-1.47.2}/pyproject.toml +1 -1
  4. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/__init__.py +1 -1
  5. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/_version.py +1 -1
  6. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/orchestrator.py +2 -0
  7. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/reflection.py +13 -7
  8. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/db.py +26 -0
  9. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/models.py +27 -0
  10. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/router.py +5 -3
  11. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/__init__.py +2 -1
  12. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/quota_routes.py +81 -5
  13. utim_cli-1.47.2/utim_cli/server/routes/referral_routes.py +248 -0
  14. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tools.py +18 -1
  15. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/model_dialog.py +13 -2
  16. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/utim.py +4 -3
  17. {utim_cli-1.47.1 → utim_cli-1.47.2/utim_cli.egg-info}/PKG-INFO +1 -1
  18. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli.egg-info/SOURCES.txt +1 -0
  19. {utim_cli-1.47.1 → utim_cli-1.47.2}/LICENSE +0 -0
  20. {utim_cli-1.47.1 → utim_cli-1.47.2}/MANIFEST.in +0 -0
  21. {utim_cli-1.47.1 → utim_cli-1.47.2}/README.md +0 -0
  22. {utim_cli-1.47.1 → utim_cli-1.47.2}/setup.cfg +0 -0
  23. {utim_cli-1.47.1 → utim_cli-1.47.2}/setup.py +0 -0
  24. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/agent.py +0 -0
  25. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/auth.py +0 -0
  26. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/backup.py +0 -0
  27. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/blender_agent.py +0 -0
  28. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/bootstrap.py +0 -0
  29. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/client_utils.py +0 -0
  30. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/config.py +0 -0
  31. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/constants.py +0 -0
  32. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/context_pruner.py +0 -0
  33. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/doctor.py +0 -0
  34. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/knowledge_graph.py +0 -0
  35. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/local_db.py +0 -0
  36. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/logger.py +0 -0
  37. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/mcp_clean_wrapper.py +0 -0
  38. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/mcp_client.py +0 -0
  39. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/mcp_registry.json +0 -0
  40. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/report.py +0 -0
  41. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/scrapy_search.py +0 -0
  42. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/__init__.py +0 -0
  43. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/auth.py +0 -0
  44. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/email_utils.py +0 -0
  45. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/exchange_rate.py +0 -0
  46. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/firebase.py +0 -0
  47. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/history.py +0 -0
  48. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/logging_config.py +0 -0
  49. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/pricing_updater.py +0 -0
  50. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/rate_limit.py +0 -0
  51. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/auth_routes.py +0 -0
  52. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/completion_routes.py +0 -0
  53. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/credit_routes.py +0 -0
  54. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/feedback_routes.py +0 -0
  55. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/session_routes.py +0 -0
  56. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/routes/share_routes.py +0 -0
  57. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/server/server.py +0 -0
  58. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/share.py +0 -0
  59. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/share_tui.py +0 -0
  60. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/situational_scoring.py +0 -0
  61. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/state.py +0 -0
  62. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/__init__.py +0 -0
  63. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/feedback_dialog.py +0 -0
  64. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/history_dialog.py +0 -0
  65. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/mcp_dialog.py +0 -0
  66. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/quota_dialog.py +0 -0
  67. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/resume_dialog.py +0 -0
  68. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/tools_dialog.py +0 -0
  69. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/tui/update_dialog.py +0 -0
  70. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/utilities.py +0 -0
  71. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/vector_memory.py +0 -0
  72. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli/workspace.py +0 -0
  73. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli.egg-info/dependency_links.txt +0 -0
  74. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli.egg-info/entry_points.txt +0 -0
  75. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli.egg-info/requires.txt +0 -0
  76. {utim_cli-1.47.1 → utim_cli-1.47.2}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,14 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.47.2] - 2026-07-18
4
+
5
+ ### Fixed
6
+ - **UnboundLocalError in Command Output Renderer**: Pre-initialized stdout and stderr lines lists in `_render_result` to prevent runtime exception on commands yielding empty output.
7
+ - **Chunk-Boundary Unicode Decode Errors**: Upgraded `read_file` tool to slice trailing incomplete bytes during UTF-8 checks, preventing decoding failures on split multi-byte character boundaries.
8
+ - **Reflection Engine Skill Creation Guardrail**: Restructured reflection engine to only create new skill files when at least 3 substantial guidelines (each >= 40 characters) are suggested, preventing clutter from useless "one-liner" skills.
9
+ - **Console Status Spinner Fallback**: Patched the `console.status` spinner in `utim.py` to check for legacy Windows CMD/conhost and non-UTF-8 environments, dynamically falling back to ASCII `line` spinners instead of unicode dots.
10
+ - **Support Chat Model Casing**: Updated frontend support assistant to target `openrouter/free` exclusively to maintain service reliability.
11
+
3
12
  ## [1.47.1] - 2026-07-18
4
13
 
5
14
  ### Added
15
+ - **Referral Program**: Launched a full referral system. Referrers earn a 2% per-plan discount for each referred user who purchases a subscription. Discounts stack per-plan up to 100% (fully free). Every payment renewal by a referee adds another 2%. Referral dashboard available at `/referral` with live discount breakdown, progress bars, copy-ready referral link/code, and a leaderboard.
6
16
  - **Instant Server-Side Deletion**: Connected client share deletion to a new `DELETE /shares/delete/{share_id}` server endpoint to destroy shared zip packages and metadata instantly when removed from the CLI dashboard.
7
17
  - **Custom Exclude Checklist Option**: Embedded a dynamic `[ Add custom files/folders... ]` checkbox directly into the share wizard to prompt for custom omits only when selected, rather than asking unconditionally.
8
18
  - **Exclusion Path Root Validation**: Added validation to reject and display warnings for typed/pasted custom paths that fall outside the current workspace root directory.
9
19
  - **Ctrl+D Dashboard Shortcut**: Resolved key collision where pressing `D` got typed in the default-focused search bar by binding `Ctrl+D` globally to delete the active share.
10
20
 
11
21
  ### Fixed
22
+ - **Content Security Policy Alignment**: Added `https://ipapi.co` and `https://analytics.google.com` to Netlify's `connect-src` CSP header rules, allowing the website frontend to complete geolocated IP checking and analytics telemetry during startup without throwing console errors or blocking the AI chat component.
12
23
  - **Unicode Markdown Rendering Fallbacks**: Patched Rich's markdown rule, table, and list item components to detect legacy Command Prompt terminals and fallback to safe ASCII representations (`*` for bullets, `-` for rules, and `+--+` for table grids) instead of throwing UnicodeEncodeErrors or printing replacement boxes.
13
24
  - **Unicode Fallback Spinners**: Re-engineered all loading/thinking spinners and progress bar characters to detect legacy Windows CMD/conhost terminals and non-UTF-8 console environments, falling back to clean ASCII patterns (`['|', '/', '-', '\\']` and `[#---]`) to prevent rendering boxes/question marks.
14
25
  - **Graceful Prompt-Toolkit Dialog Exits**: Introduced a safe-exit mechanism to catch and prevent unhandled `Application.exit()` loop exceptions.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 1.47.1
3
+ Version: 1.47.2
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "1.47.1"
7
+ version = "1.47.2"
8
8
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "1.47.1"
8
+ __version__ = "1.47.2"
9
9
 
10
10
  import builtins, os, pathlib, urllib.parse, re
11
11
 
@@ -11,4 +11,4 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "1.47.1"
14
+ VERSION = "1.47.2"
@@ -2330,6 +2330,8 @@ class Orchestrator:
2330
2330
  stdout_section = ""
2331
2331
  stderr_section = ""
2332
2332
  current_section = None
2333
+ stdout_lines = []
2334
+ stderr_lines = []
2333
2335
 
2334
2336
  for line in raw_output.splitlines():
2335
2337
  if line.startswith("[exit_code:"):
@@ -969,15 +969,24 @@ def apply_skill_modifications(skill_modifications: Dict[str, List[str]]):
969
969
 
970
970
  for skill_path in paths_to_write:
971
971
  try:
972
- # Ensure parent directory exists
973
- skill_path.parent.mkdir(parents=True, exist_ok=True)
972
+ skill_exists = skill_path.exists()
973
+
974
+ # Filter rules by length and substance based on whether the skill already exists
975
+ min_len = 25 if skill_exists else 40
976
+ valid_rules = [r.strip() for r in new_rules if r and len(r.strip()) >= min_len]
977
+
978
+ # Enforce strict guardrail for new skills: must have at least 3 substantial rules
979
+ if not skill_exists and len(valid_rules) < 3:
980
+ continue # Ignore creation of this new skill entirely
974
981
 
975
982
  content = ""
976
- if skill_path.exists():
983
+ if skill_exists:
977
984
  with open(skill_path, 'r', encoding='utf-8') as f:
978
985
  content = f.read()
979
986
 
980
987
  if not content:
988
+ # Ensure parent directory exists before writing
989
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
981
990
  # Initialize a new skill md template
982
991
  title_name = skill_name.replace('-', ' ').title()
983
992
  content = f"""---
@@ -992,10 +1001,7 @@ description: Automatically learned guidelines for {title_name}.
992
1001
 
993
1002
  # Append new rules avoiding duplicates
994
1003
  rules_to_add = []
995
- for rule in new_rules:
996
- rule_clean = rule.strip()
997
- if not rule_clean:
998
- continue
1004
+ for rule_clean in valid_rules:
999
1005
  # Check if already present in content (case-insensitive substring check)
1000
1006
  if rule_clean.lower() not in content.lower():
1001
1007
  rules_to_add.append(rule_clean)
@@ -54,7 +54,10 @@ class User(Base):
54
54
  created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
55
55
  last_project_folder = Column(Text, nullable=True)
56
56
  firebase_uid = Column(String(128), nullable=True, index=True)
57
+ referrer_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
58
+ referral_code = Column(String(50), unique=True, nullable=False, default=lambda: uuid.uuid4().hex[:8])
57
59
 
60
+ referrer = relationship("User", remote_side=[id], backref="referees")
58
61
  credits = relationship("Credit", back_populates="user", uselist=False, cascade="all, delete-orphan")
59
62
  conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
60
63
  transactions = relationship("Transaction", back_populates="user", cascade="all, delete-orphan")
@@ -472,6 +475,14 @@ def init_db(silent: bool = True) -> None:
472
475
  # Run manual migrations for missing columns
473
476
  # Each ALTER runs in its OWN transaction so one failure doesn't block the rest.
474
477
  from sqlalchemy import text, inspect as sa_inspect
478
+
479
+ # Ensure referral_discounts table exists (defined in referral_routes, not imported into Base yet)
480
+ try:
481
+ from .routes.referral_routes import ReferralDiscount
482
+ Base.metadata.create_all(bind=engine, tables=[ReferralDiscount.__table__])
483
+ except Exception as _ref_tbl_exc:
484
+ _print(f"[DB INIT] referral_discounts table init note: {_ref_tbl_exc}")
485
+
475
486
  is_postgres = DATABASE_URL.startswith("postgresql")
476
487
 
477
488
  try:
@@ -512,6 +523,8 @@ def init_db(silent: bool = True) -> None:
512
523
  _print("[DB INIT] Postgres detected. Running ALTER TABLE migrations if needed.")
513
524
  _run_migration("credits", "free_monthly_used", "ALTER TABLE credits ADD COLUMN IF NOT EXISTS free_monthly_used FLOAT DEFAULT 0.0 NOT NULL", "postgres add free_monthly_used")
514
525
  _run_migration("users", "firebase_uid", "ALTER TABLE users ADD COLUMN IF NOT EXISTS firebase_uid VARCHAR(128)", "postgres add firebase_uid")
526
+ _run_migration("users", "referrer_id", "ALTER TABLE users ADD COLUMN IF NOT EXISTS referrer_id VARCHAR(36)", "postgres add referrer_id")
527
+ _run_migration("users", "referral_code", "ALTER TABLE users ADD COLUMN IF NOT EXISTS referral_code VARCHAR(50)", "postgres add referral_code")
515
528
  _run_migration("feedbacks", "chat_history", "ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS chat_history TEXT", "postgres add chat_history")
516
529
  else:
517
530
  # SQLite: no IF NOT EXISTS on ADD COLUMN, swallow duplicate errors
@@ -526,6 +539,8 @@ def init_db(silent: bool = True) -> None:
526
539
  _run_migration("email_tracking", "bonus_email_sent_at", "ALTER TABLE email_tracking ADD COLUMN bonus_email_sent_at TIMESTAMP", "sqlite add bonus_email_sent_at")
527
540
  _run_migration("users", "last_project_folder", "ALTER TABLE users ADD COLUMN last_project_folder TEXT", "sqlite add last_project_folder")
528
541
  _run_migration("users", "firebase_uid", "ALTER TABLE users ADD COLUMN firebase_uid VARCHAR(128)", "sqlite add firebase_uid")
542
+ _run_migration("users", "referrer_id", "ALTER TABLE users ADD COLUMN referrer_id VARCHAR(36)", "sqlite add referrer_id")
543
+ _run_migration("users", "referral_code", "ALTER TABLE users ADD COLUMN referral_code VARCHAR(50)", "sqlite add referral_code")
529
544
  _run_migration("feedbacks", "chat_history", "ALTER TABLE feedbacks ADD COLUMN chat_history TEXT", "sqlite add feedbacks chat_history")
530
545
 
531
546
  # Seed the plans if they don't exist
@@ -654,6 +669,17 @@ def init_db(silent: bool = True) -> None:
654
669
  ultimate_plan.credits_per_month = 90000
655
670
  if ultimate_plan.price_inr != 11000:
656
671
  ultimate_plan.price_inr = 11000
672
+
673
+ # Generate referral codes for existing users that don't have one
674
+ try:
675
+ users_without_code = db.query(User).filter(User.referral_code == None).all()
676
+ if users_without_code:
677
+ _print(f"[DB INIT] Generating referral codes for {len(users_without_code)} users...")
678
+ for u in users_without_code:
679
+ u.referral_code = uuid.uuid4().hex[:8]
680
+ except Exception as ref_exc:
681
+ _print(f"[DB INIT] Referral code migration check failed: {ref_exc}")
682
+
657
683
  db.commit()
658
684
  except Exception as exc:
659
685
  db.rollback()
@@ -1116,6 +1116,33 @@ MODEL_REGISTRY: dict[MODEL_ID, ModelEntry] = {
1116
1116
  capabilities=["chat", "code"],
1117
1117
  tags=["premium"],
1118
1118
  ),
1119
+ "thinkingmachines/inkling": ModelEntry(
1120
+ model_id="thinkingmachines/inkling",
1121
+ provider="openrouter",
1122
+ cost_input_per_1k=0.00105,
1123
+ cost_output_per_1k=0.0042525,
1124
+ context_window=1_048_576,
1125
+ capabilities=["chat", "code"],
1126
+ tags=["premium"],
1127
+ ),
1128
+ "moonshotai/kimi-k3": ModelEntry(
1129
+ model_id="moonshotai/kimi-k3",
1130
+ provider="openrouter",
1131
+ cost_input_per_1k=0.00315,
1132
+ cost_output_per_1k=0.01575,
1133
+ context_window=1_000_000,
1134
+ capabilities=["chat", "reasoning"],
1135
+ tags=["premium", "reasoning"],
1136
+ ),
1137
+ "meta/muse-spark-1.1": ModelEntry(
1138
+ model_id="meta/muse-spark-1.1",
1139
+ provider="openrouter",
1140
+ cost_input_per_1k=0.0013125,
1141
+ cost_output_per_1k=0.0044625,
1142
+ context_window=1_048_576,
1143
+ capabilities=["chat", "code", "multimodal"],
1144
+ tags=["premium", "multimodal"],
1145
+ ),
1119
1146
  }
1120
1147
 
1121
1148
  # Default model used when the user does not specify one
@@ -26,7 +26,7 @@ from .logging_config import configure_logging, RequestLoggingMiddleware
26
26
  from .rate_limit import limiter
27
27
  from slowapi import _rate_limit_exceeded_handler
28
28
  from slowapi.errors import RateLimitExceeded
29
- from .routes import auth_router, credit_router, session_router, completion_router, quota_router, share_router, feedback_router
29
+ from .routes import auth_router, credit_router, session_router, completion_router, quota_router, share_router, feedback_router, referral_router
30
30
  from .models import list_models
31
31
  from .auth import get_admin_user
32
32
 
@@ -112,6 +112,7 @@ app.include_router(completion_router)
112
112
  app.include_router(quota_router)
113
113
  app.include_router(share_router)
114
114
  app.include_router(feedback_router)
115
+ app.include_router(referral_router)
115
116
 
116
117
  # ── Support Chatbot Endpoint ──────────────────────────────────────────────────
117
118
 
@@ -189,8 +190,9 @@ async def support_chat(request: Request, req: SupportChatRequest):
189
190
  import json
190
191
  ALLOWED_FREE_MODELS = {
191
192
  "poolside/laguna-xs.2:free",
192
- "google/gemma-2-9b-it:free",
193
193
  "cohere/north-mini-code:free",
194
+ "google/gemma-4-31b-it:free",
195
+ "google/gemma-4-26b-a4b-it:free",
194
196
  "openrouter/free"
195
197
  }
196
198
 
@@ -210,7 +212,7 @@ async def support_chat(request: Request, req: SupportChatRequest):
210
212
 
211
213
  # Generate candidate models starting with the requested one
212
214
  candidate_models = [model_name]
213
- for fallback in ["openrouter/free", "google/gemma-2-9b-it:free", "cohere/north-mini-code:free", "poolside/laguna-xs.2:free"]:
215
+ for fallback in ["openrouter/free", "cohere/north-mini-code:free", "google/gemma-4-31b-it:free", "poolside/laguna-xs.2:free"]:
214
216
  if fallback not in candidate_models:
215
217
  candidate_models.append(fallback)
216
218
 
@@ -5,6 +5,7 @@ from .completion_routes import router as completion_router
5
5
  from .quota_routes import router as quota_router
6
6
  from .share_routes import router as share_router
7
7
  from .feedback_routes import router as feedback_router
8
+ from .referral_routes import router as referral_router
8
9
 
9
- __all__ = ["auth_router", "credit_router", "session_router", "completion_router", "quota_router", "share_router", "feedback_router"]
10
+ __all__ = ["auth_router", "credit_router", "session_router", "completion_router", "quota_router", "share_router", "feedback_router", "referral_router"]
10
11
 
@@ -1196,6 +1196,23 @@ def api_subscription_create(
1196
1196
 
1197
1197
  currency = (req.currency or "INR").upper().strip()
1198
1198
 
1199
+ # Fetch user's active referral discounts
1200
+ from .referral_routes import get_referrer_discounts
1201
+ try:
1202
+ discounts = get_referrer_discounts(db, user.id)
1203
+ except Exception:
1204
+ discounts = {}
1205
+
1206
+ discount_pct = min(100.0, discounts.get(db_plan_id, 0.0))
1207
+
1208
+ # If the user has a 100% discount, bypass Razorpay payment entirely
1209
+ if discount_pct >= 100.0:
1210
+ return {
1211
+ "success": True,
1212
+ "subscriptionId": f"free_referral_100pct_{db_plan_id}_monthly",
1213
+ "keyId": "free"
1214
+ }
1215
+
1199
1216
  if not key_id or key_id == "mock_key_id":
1200
1217
  return {
1201
1218
  "success": True,
@@ -1205,7 +1222,48 @@ def api_subscription_create(
1205
1222
 
1206
1223
  import requests
1207
1224
 
1208
- env_plan_id = get_razorpay_plan_id(db_plan_id, interval, currency)
1225
+ # Try to load discounted plan from environment variables or JSON mapping
1226
+ env_plan_id = None
1227
+ if 0.0 < discount_pct < 100.0:
1228
+ # Map to closest step of 2 (2%, 4%, ..., 98%)
1229
+ pct_step = int(round(discount_pct / 2) * 2)
1230
+ pct_step = max(2, min(98, pct_step))
1231
+
1232
+ # Determine variable keys checking aliases
1233
+ plan_env_name = db_plan_id.upper()
1234
+ if plan_env_name == "PRO":
1235
+ plan_env_name = "STARTER"
1236
+ elif plan_env_name == "MAX":
1237
+ plan_env_name = "PROFESSIONAL"
1238
+
1239
+ env_key = f"RAZORPAY_PLAN_{plan_env_name}_{pct_step}"
1240
+ env_plan_id = os.environ.get(env_key)
1241
+
1242
+ # Fallback check for direct DB plan name
1243
+ if not env_plan_id:
1244
+ env_key_direct = f"RAZORPAY_PLAN_{db_plan_id.upper()}_{pct_step}"
1245
+ env_plan_id = os.environ.get(env_key_direct)
1246
+
1247
+ if env_plan_id:
1248
+ logger.info(f"Dynamic discounted plan resolved from environment: {env_plan_id} ({env_key})")
1249
+ else:
1250
+ # Fallback to local JSON mapping file
1251
+ import json
1252
+ json_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "razorpay_discounted_plans.json")
1253
+ if os.path.exists(json_path):
1254
+ try:
1255
+ with open(json_path, "r", encoding="utf-8") as f:
1256
+ plans_map = json.load(f)
1257
+ plan_key = f"{db_plan_id}_{pct_step}"
1258
+ env_plan_id = plans_map.get(plan_key)
1259
+ if env_plan_id:
1260
+ logger.info(f"Dynamic discounted plan resolved from JSON: {env_plan_id} for user {user.id} ({plan_key})")
1261
+ except Exception as json_err:
1262
+ logger.error(f"Failed to read discounted plans JSON mapping: {json_err}")
1263
+
1264
+ if not env_plan_id:
1265
+ env_plan_id = get_razorpay_plan_id(db_plan_id, interval, currency)
1266
+
1209
1267
  if not env_plan_id:
1210
1268
  raise HTTPException(
1211
1269
  status_code=400,
@@ -1254,15 +1312,26 @@ def api_subscription_verify(
1254
1312
  key_id = os.environ.get("RAZORPAY_KEY_ID", "")
1255
1313
  key_secret = os.environ.get("RAZORPAY_KEY_SECRET", "")
1256
1314
 
1257
- if req.razorpay_subscription_id.startswith("sub_mock") or not key_id or key_id == "mock_key_id":
1315
+ if req.razorpay_subscription_id.startswith("free_referral_100pct") or req.razorpay_subscription_id.startswith("sub_mock") or not key_id or key_id == "mock_key_id":
1258
1316
  plan_id = "hobby"
1259
1317
  interval = "monthly"
1260
1318
  parts = req.razorpay_subscription_id.split("_")
1261
- if len(parts) >= 3 and parts[2] in ("hobby", "pro", "max", "ultimate"):
1319
+ # free_referral_100pct_{plan_id}_monthly
1320
+ if len(parts) >= 4 and parts[3] in ("hobby", "pro", "max", "ultimate"):
1321
+ plan_id = parts[3]
1322
+ elif len(parts) >= 3 and parts[2] in ("hobby", "pro", "max", "ultimate"):
1262
1323
  plan_id = parts[2]
1263
- if len(parts) >= 4 and parts[3] in ("monthly", "yearly"):
1324
+ if len(parts) >= 5 and parts[4] in ("monthly", "yearly"):
1325
+ interval = parts[4]
1326
+ elif len(parts) >= 4 and parts[3] in ("monthly", "yearly"):
1264
1327
  interval = parts[3]
1265
1328
  activate_subscription(db, user_id=user.id, plan_id=plan_id, razorpay_sub_id=req.razorpay_subscription_id, interval=interval)
1329
+ # Award referral discount to referrer if applicable
1330
+ try:
1331
+ from .referral_routes import apply_referral_purchase
1332
+ apply_referral_purchase(db, referee_id=user.id, plan_id=plan_id)
1333
+ except Exception as _ref_err:
1334
+ logger.warning(f"referral_purchase_hook_failed: {_ref_err}")
1266
1335
  return {"success": True}
1267
1336
 
1268
1337
  import hmac
@@ -1304,7 +1373,14 @@ def api_subscription_verify(
1304
1373
  razorpay_cust_id=f"cust_{user.id[:8]}",
1305
1374
  interval=interval
1306
1375
  )
1307
-
1376
+
1377
+ # Award referral discount to referrer if applicable
1378
+ try:
1379
+ from .referral_routes import apply_referral_purchase
1380
+ apply_referral_purchase(db, referee_id=user.id, plan_id=plan_id)
1381
+ except Exception as _ref_err:
1382
+ logger.warning(f"referral_purchase_hook_failed: {_ref_err}")
1383
+
1308
1384
  return {"success": True}
1309
1385
 
1310
1386
 
@@ -0,0 +1,248 @@
1
+ """
2
+ Referral System API routes.
3
+
4
+ Logic:
5
+ - Each user has a unique referral_code (8 hex chars stored on the User model).
6
+ - When a new user signs up with ?ref=<code>, the server stores the referrer's ID on their account.
7
+ - Every time a referred user's subscription payment is verified, the referrer earns a 2% discount
8
+ on that exact plan (e.g. if B buys hobby, referrer gets 2% off hobby).
9
+ - Discounts stack per-plan up to 100%. If the same referred user renews, the discount
10
+ increments again.
11
+ - The discount is stored in a separate ReferralDiscount table so it's queryable per plan.
12
+ """
13
+
14
+ import uuid
15
+ import datetime
16
+ import logging
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException
19
+ from pydantic import BaseModel
20
+ from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey, UniqueConstraint
21
+ from sqlalchemy.orm import Session, relationship
22
+
23
+ from ..db import Base, get_db, SessionLocal
24
+ from ..auth import get_current_firebase_user
25
+ from ..db import User
26
+
27
+ logger = logging.getLogger("utim.referrals")
28
+
29
+ router = APIRouter()
30
+
31
+
32
+ # ── ORM Model ─────────────────────────────────────────────────────────────────
33
+
34
+ class ReferralDiscount(Base):
35
+ """Tracks per-plan discount percentage earned by a referrer."""
36
+ __tablename__ = "referral_discounts"
37
+ __table_args__ = (
38
+ UniqueConstraint("referrer_id", "plan_id", name="uq_referral_discount_referrer_plan"),
39
+ )
40
+
41
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
42
+ referrer_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
43
+ plan_id = Column(String(50), nullable=False) # e.g. "hobby", "pro", "max"
44
+ discount_pct = Column(Float, default=0.0, nullable=False) # 0-100
45
+ referee_count = Column(Integer, default=0, nullable=False) # how many distinct referral purchases stacked this
46
+ updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
47
+
48
+ referrer = relationship("User", foreign_keys=[referrer_id], backref="referral_discounts")
49
+
50
+
51
+ # ── Pydantic Schemas ───────────────────────────────────────────────────────────
52
+
53
+ class RegisterReferralRequest(BaseModel):
54
+ referral_code: str # code of the referrer
55
+
56
+
57
+ # ── Helpers ────────────────────────────────────────────────────────────────────
58
+
59
+ MAX_DISCOUNT_PCT = 100.0
60
+ DISCOUNT_PER_REFERRAL = 2.0
61
+
62
+
63
+ def apply_referral_purchase(db: Session, referee_id: str, plan_id: str) -> bool:
64
+ """
65
+ Called after a successful payment by `referee_id`.
66
+ Finds their referrer and increments the plan-specific discount by 2%.
67
+ Returns True if a referrer was found and updated.
68
+ """
69
+ referee = db.query(User).filter(User.id == referee_id).first()
70
+ if not referee or not referee.referrer_id:
71
+ return False
72
+
73
+ referrer_id = referee.referrer_id
74
+
75
+ # Normalise plan alias
76
+ normalised = _normalise_plan(plan_id)
77
+
78
+ discount_row = (
79
+ db.query(ReferralDiscount)
80
+ .filter(
81
+ ReferralDiscount.referrer_id == referrer_id,
82
+ ReferralDiscount.plan_id == normalised,
83
+ )
84
+ .first()
85
+ )
86
+
87
+ if not discount_row:
88
+ discount_row = ReferralDiscount(
89
+ referrer_id=referrer_id,
90
+ plan_id=normalised,
91
+ discount_pct=0.0,
92
+ referee_count=0,
93
+ )
94
+ db.add(discount_row)
95
+
96
+ new_pct = min(MAX_DISCOUNT_PCT, discount_row.discount_pct + DISCOUNT_PER_REFERRAL)
97
+ discount_row.discount_pct = new_pct
98
+ discount_row.referee_count += 1
99
+ discount_row.updated_at = datetime.datetime.utcnow()
100
+
101
+ try:
102
+ db.commit()
103
+ except Exception as e:
104
+ db.rollback()
105
+ logger.error(f"referral_discount_commit_error: {e}")
106
+ return False
107
+
108
+ logger.info(
109
+ f"referral_discount_updated referrer={referrer_id} plan={normalised} "
110
+ f"new_pct={new_pct} referee_count={discount_row.referee_count}"
111
+ )
112
+ return True
113
+
114
+
115
+ def _normalise_plan(plan_id: str) -> str:
116
+ """Map frontend plan aliases to canonical DB plan ids."""
117
+ mapping = {"starter": "pro", "professional": "max", "ultimate": "ultimate"}
118
+ pid = plan_id.lower().strip()
119
+ return mapping.get(pid, pid)
120
+
121
+
122
+ def get_referrer_discounts(db: Session, referrer_id: str) -> dict:
123
+ """Return a dict of plan_id -> discount_pct for a given referrer."""
124
+ rows = db.query(ReferralDiscount).filter(ReferralDiscount.referrer_id == referrer_id).all()
125
+ return {r.plan_id: round(r.discount_pct, 2) for r in rows}
126
+
127
+
128
+ # ── Routes ─────────────────────────────────────────────────────────────────────
129
+
130
+ @router.post("/api/referrals/register")
131
+ def register_referral(
132
+ req: RegisterReferralRequest,
133
+ db: Session = Depends(get_db),
134
+ user: User = Depends(get_current_firebase_user),
135
+ ):
136
+ """
137
+ Link the current user to a referrer by their referral_code.
138
+ Can only be called once per user (if they don't already have a referrer).
139
+ """
140
+ if user.referrer_id:
141
+ raise HTTPException(status_code=400, detail="You have already been linked to a referrer.")
142
+
143
+ referrer = db.query(User).filter(User.referral_code == req.referral_code.strip()).first()
144
+ if not referrer:
145
+ raise HTTPException(status_code=404, detail="Referral code not found.")
146
+
147
+ if referrer.id == user.id:
148
+ raise HTTPException(status_code=400, detail="You cannot use your own referral code.")
149
+
150
+ user.referrer_id = referrer.id
151
+ try:
152
+ db.commit()
153
+ except Exception as e:
154
+ db.rollback()
155
+ raise HTTPException(status_code=500, detail=f"Failed to link referral: {e}")
156
+
157
+ logger.info(f"referral_linked referee={user.id} referrer={referrer.id}")
158
+ return {"success": True, "message": f"Successfully linked to referrer."}
159
+
160
+
161
+ @router.get("/api/referrals/info")
162
+ def get_referral_info(
163
+ db: Session = Depends(get_db),
164
+ user: User = Depends(get_current_firebase_user),
165
+ ):
166
+ """
167
+ Returns the current user's referral code, stats, and any discounts they have earned
168
+ as a referrer.
169
+ """
170
+ # Ensure user has a referral code (backfill for old accounts)
171
+ if not user.referral_code:
172
+ user.referral_code = uuid.uuid4().hex[:8]
173
+ try:
174
+ db.commit()
175
+ except Exception:
176
+ db.rollback()
177
+
178
+ # Count how many users they've referred
179
+ try:
180
+ referee_count = db.query(User).filter(User.referrer_id == user.id).count()
181
+ except Exception:
182
+ referee_count = 0
183
+
184
+ # Get their earned discounts
185
+ try:
186
+ discounts = get_referrer_discounts(db, user.id)
187
+ except Exception:
188
+ discounts = {}
189
+
190
+ # Did they sign up via a referral?
191
+ referred_by = None
192
+ if user.referrer_id:
193
+ try:
194
+ referrer = db.query(User).filter(User.id == user.referrer_id).first()
195
+ if referrer:
196
+ referred_by = {
197
+ "display_name": referrer.display_name or referrer.email.split("@")[0],
198
+ "email_hint": referrer.email[:3] + "***"
199
+ }
200
+ except Exception:
201
+ pass
202
+
203
+ return {
204
+ "referral_code": user.referral_code,
205
+ "referral_url": f"https://utim.dev/auth?ref={user.referral_code}",
206
+ "referee_count": referee_count,
207
+ "discounts": discounts,
208
+ "referred_by": referred_by,
209
+ }
210
+
211
+
212
+ @router.get("/api/referrals/leaderboard")
213
+ def referral_leaderboard(
214
+ db: Session = Depends(get_db),
215
+ user: User = Depends(get_current_firebase_user),
216
+ ):
217
+ """
218
+ Returns the top referrers by total referee count (anonymized).
219
+ Also returns current user's rank.
220
+ """
221
+ from sqlalchemy import text
222
+ try:
223
+ result = db.execute(
224
+ text("""
225
+ SELECT u.id, u.display_name, COUNT(r.id) as cnt
226
+ FROM users u
227
+ LEFT JOIN users r ON r.referrer_id = u.id
228
+ GROUP BY u.id, u.display_name
229
+ HAVING COUNT(r.id) > 0
230
+ ORDER BY cnt DESC
231
+ LIMIT 10
232
+ """)
233
+ ).fetchall()
234
+
235
+ board = [
236
+ {
237
+ "rank": i + 1,
238
+ "name": (row[1] or "Anonymous")[:15],
239
+ "referrals": row[2],
240
+ "is_me": row[0] == user.id,
241
+ }
242
+ for i, row in enumerate(result)
243
+ ]
244
+ except Exception as e:
245
+ logger.error(f"leaderboard_query_error: {e}")
246
+ board = []
247
+
248
+ return {"leaderboard": board}
@@ -102,11 +102,28 @@ def read_file(filepath: str, start_line: int = None, end_line: int = None) -> st
102
102
  chunk = f.read(2048)
103
103
  if b"\x00" in chunk:
104
104
  return f"Error: '{filepath}' appears to be a binary file and cannot be read as text."
105
+
105
106
  # Test decode
107
+ decode_success = False
106
108
  try:
107
109
  chunk.decode("utf-8")
110
+ decode_success = True
108
111
  except UnicodeDecodeError:
109
- return f"Error: '{filepath}' uses an unsupported encoding and cannot be read as text."
112
+ # If it's a split multi-byte character at the end of the chunk, check smaller chunks
113
+ for i in range(1, min(5, len(chunk))):
114
+ try:
115
+ chunk[:-i].decode("utf-8")
116
+ decode_success = True
117
+ break
118
+ except UnicodeDecodeError:
119
+ continue
120
+
121
+ if not decode_success:
122
+ # Bypass check for known text extensions
123
+ ext = _os.path.splitext(filepath)[1].lower()
124
+ text_extensions = {'.txt', '.md', '.py', '.js', '.ts', '.jsx', '.tsx', '.json', '.yaml', '.yml', '.ini', '.cfg', '.conf', '.sh', '.bat', '.ps1', '.html', '.css', '.csv', '.xml', '.toml'}
125
+ if ext not in text_extensions:
126
+ return f"Error: '{filepath}' uses an unsupported encoding and cannot be read as text."
110
127
 
111
128
  with open(filepath, "r", encoding="utf-8", errors="replace") as f:
112
129
  all_lines = f.readlines()
@@ -214,6 +214,9 @@ def _dialog_model_main(orchestrator, target="main"):
214
214
  "google/gemini-3.5-flash",
215
215
  "qwen/qwen3.7-max",
216
216
  "stepfun/step-3.7-flash",
217
+ "thinkingmachines/inkling",
218
+ "moonshotai/kimi-k3",
219
+ "meta/muse-spark-1.1",
217
220
 
218
221
  "anthropic/claude-sonnet-4.5",
219
222
  "anthropic/claude-sonnet-4.6",
@@ -284,11 +287,13 @@ def _dialog_model_main(orchestrator, target="main"):
284
287
  "qwen/qwen3.7-max",
285
288
  "qwen/qwen3.6-plus",
286
289
  "openai/gpt-5.4-mini",
287
- "minimax/minimax-m2.5",
288
290
  "x-ai/grok-4.20",
289
291
  "x-ai/grok-build-0.1",
290
292
  "moonshotai/kimi-k2.5",
291
- })
293
+ "thinkingmachines/inkling",
294
+ "moonshotai/kimi-k3",
295
+ "meta/muse-spark-1.1",
296
+ })
292
297
  elif target == "subagent_image_gen":
293
298
  # subagent_image_gen
294
299
  approved_set = {
@@ -407,6 +412,9 @@ def _dialog_model_main(orchestrator, target="main"):
407
412
  "google/gemini-3.5-flash": (3, "High"),
408
413
  "qwen/qwen3.7-max": (3, "High"),
409
414
  "stepfun/step-3.7-flash": (1, "Low"),
415
+ "thinkingmachines/inkling": (0, "Very low"),
416
+ "moonshotai/kimi-k3": (1, "Low"),
417
+ "meta/muse-spark-1.1": (0, "Very low"),
410
418
 
411
419
  # Free NVIDIA models:
412
420
 
@@ -500,6 +508,9 @@ def _dialog_model_main(orchestrator, target="main"):
500
508
  "google/gemini-3.5-flash": ("Fast, multimodal and agentic model by Google.", []),
501
509
  "qwen/qwen3.7-max": ("Flagship agentic and reasoning model by Qwen.", []),
502
510
  "stepfun/step-3.7-flash": ("Cost-effective multimodal assistant by StepFun.", []),
511
+ "thinkingmachines/inkling": ("Large-context, code-optimized reasoning model by Thinking Machines Lab.", ["premium"]),
512
+ "moonshotai/kimi-k3": ("Frontier-class reasoning MoE model by Moonshot AI.", ["premium", "reasoning"]),
513
+ "meta/muse-spark-1.1": ("Multimodal reasoning and agentic model by Meta Superintelligence Labs.", ["premium", "multimodal"]),
503
514
 
504
515
  # Free NVIDIA models:
505
516
 
@@ -939,7 +939,7 @@ def _print_animated_banner(animated: bool = True):
939
939
  else:
940
940
  console.print(f"[bold {color}]{BANNER_BIG}[/bold {color}]")
941
941
 
942
- console.print(f" [bold white]U Think I Make[/bold white] [dim]v1.47.1[/dim]")
942
+ console.print(f" [bold white]U Think I Make[/bold white] [dim]v1.47.2[/dim]")
943
943
  user_email = config.email
944
944
  user_type = "UTIM Community"
945
945
  api_key = config.get("api_key")
@@ -3521,7 +3521,7 @@ def check_and_update_background():
3521
3521
  return
3522
3522
 
3523
3523
  latest_ver = resp.json().get("version", "")
3524
- current_ver = "1.47.1"
3524
+ current_ver = "1.47.2"
3525
3525
 
3526
3526
  def parse_ver(v):
3527
3527
  return [int(x) for x in v.split(".") if x.isdigit()]
@@ -4154,7 +4154,8 @@ def upgrade():
4154
4154
  console.print(" [yellow]Upgrade cancelled.[/yellow]\n")
4155
4155
  return
4156
4156
 
4157
- with console.status("[bold green]Creating subscription link...", spinner="dots"):
4157
+ spinner_type = "line" if (_IS_LEGACY_WIN or getattr(sys.stdout, 'encoding', '').lower() not in ('utf-8', 'utf8', 'cp65001')) else "dots"
4158
+ with console.status("[bold green]Creating subscription link...", spinner=spinner_type):
4158
4159
  try:
4159
4160
  resp = requests.post(
4160
4161
  f"{SERVER_URL}/subscribe",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 1.47.1
3
+ Version: 1.47.2
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://utim.dev
@@ -60,6 +60,7 @@ utim_cli/server/routes/completion_routes.py
60
60
  utim_cli/server/routes/credit_routes.py
61
61
  utim_cli/server/routes/feedback_routes.py
62
62
  utim_cli/server/routes/quota_routes.py
63
+ utim_cli/server/routes/referral_routes.py
63
64
  utim_cli/server/routes/session_routes.py
64
65
  utim_cli/server/routes/share_routes.py
65
66
  utim_cli/tui/__init__.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes