Coverage for integrations / agent_engine / app_marketplace.py: 14.2%
727 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2App Marketplace — Google Play + Microsoft Store + Apple App Store for HART OS.
4Full marketplace backend for AI apps/agents built on HART OS.
5Users publish agents (recipes) as apps, other users install and rate them,
6auto-promotion distributes to 30+ channels, and the 90/9/1 revenue split
7ensures creators keep 90% of every Spark earned.
9JSON-backed listing registry at agent_data/marketplace/ (no schema migration).
10Thread-safe, atomic writes, EventBus integration.
12Singleton: get_marketplace()
13Blueprint: marketplace_bp (register on Flask app)
14Goal seed: SEED_APP_MARKETPLACE_PROMOTER
15"""
17import json
18import logging
19import math
20import os
21import threading
22import time
23import uuid
24from datetime import datetime, timedelta
25from typing import Any, Dict, List, Optional, Tuple
27logger = logging.getLogger('hevolve.marketplace')
29# ─── Persistence paths ─────────────────────────────────────────────────────
30_MARKETPLACE_DIR = os.path.join('agent_data', 'marketplace')
31_LISTINGS_PATH = os.path.join(_MARKETPLACE_DIR, 'listings.json')
32_REVIEWS_PATH = os.path.join(_MARKETPLACE_DIR, 'reviews.json')
33_INSTALLS_PATH = os.path.join(_MARKETPLACE_DIR, 'installs.json')
34_REVENUE_PATH = os.path.join(_MARKETPLACE_DIR, 'revenue.json')
36# ─── Categories ─────────────────────────────────────────────────────────────
38APP_CATEGORIES = [
39 'productivity', 'coding', 'research', 'writing', 'design',
40 'marketing', 'finance', 'education', 'health', 'entertainment',
41 'social', 'automation', 'data_analysis', 'customer_support',
42 'translation', 'legal', 'hr', 'devops', 'security', 'general',
43]
45# ─── Pricing models ────────────────────────────────────────────────────────
47PRICING_MODELS = ['free', 'freemium', 'paid', 'subscription']
49# ─── Platform targets ──────────────────────────────────────────────────────
51SUPPORTED_PLATFORMS = ['windows', 'linux', 'mac', 'android', 'ios', 'web']
53# ─── Revenue split (imports canonical constants) ───────────────────────────
55try:
56 from integrations.agent_engine.revenue_aggregator import (
57 REVENUE_SPLIT_USERS, REVENUE_SPLIT_INFRA, REVENUE_SPLIT_CENTRAL,
58 )
59except ImportError:
60 REVENUE_SPLIT_USERS = 0.90
61 REVENUE_SPLIT_INFRA = 0.09
62 REVENUE_SPLIT_CENTRAL = 0.01
65# ═══════════════════════════════════════════════════════════════════════════
66# Atomic JSON persistence
67# ═══════════════════════════════════════════════════════════════════════════
69def _ensure_dir():
70 os.makedirs(_MARKETPLACE_DIR, exist_ok=True)
73def _atomic_write(path: str, data: Any) -> None:
74 """Write JSON atomically (write .tmp, then os.replace)."""
75 _ensure_dir()
76 tmp = path + '.tmp'
77 with open(tmp, 'w', encoding='utf-8') as f:
78 json.dump(data, f, indent=2, default=str)
79 os.replace(tmp, path)
82def _load_json(path: str, default: Any = None) -> Any:
83 """Load JSON file, returning default if absent or corrupt."""
84 if not os.path.isfile(path):
85 return default if default is not None else {}
86 try:
87 with open(path, 'r', encoding='utf-8') as f:
88 return json.load(f)
89 except (json.JSONDecodeError, IOError):
90 logger.warning(f"Corrupt marketplace file: {path}, returning default")
91 return default if default is not None else {}
94# ═══════════════════════════════════════════════════════════════════════════
95# EventBus helpers
96# ═══════════════════════════════════════════════════════════════════════════
98def _emit(topic: str, data: Dict) -> None:
99 """Emit event on platform EventBus (safe no-op if not bootstrapped)."""
100 try:
101 from core.platform.events import emit_event
102 emit_event(topic, data)
103 except Exception:
104 pass
107# ═══════════════════════════════════════════════════════════════════════════
108# AppMarketplace
109# ═══════════════════════════════════════════════════════════════════════════
111class AppMarketplace:
112 """Full-featured marketplace for AI apps/agents built on HART OS.
114 JSON-backed registry at agent_data/marketplace/. Thread-safe mutations.
115 Every sale follows the 90/9/1 revenue split.
116 """
118 def __init__(self):
119 self._lock = threading.Lock()
120 _ensure_dir()
122 # ─── Private persistence helpers ───────────────────────────────────
124 def _listings(self) -> Dict[str, Dict]:
125 return _load_json(_LISTINGS_PATH, {})
127 def _save_listings(self, data: Dict[str, Dict]) -> None:
128 _atomic_write(_LISTINGS_PATH, data)
130 def _reviews(self) -> Dict[str, List[Dict]]:
131 return _load_json(_REVIEWS_PATH, {})
133 def _save_reviews(self, data: Dict[str, List[Dict]]) -> None:
134 _atomic_write(_REVIEWS_PATH, data)
136 def _installs(self) -> Dict[str, List[Dict]]:
137 return _load_json(_INSTALLS_PATH, {})
139 def _save_installs(self, data: Dict[str, List[Dict]]) -> None:
140 _atomic_write(_INSTALLS_PATH, data)
142 def _revenue(self) -> Dict[str, List[Dict]]:
143 return _load_json(_REVENUE_PATH, {})
145 def _save_revenue(self, data: Dict[str, List[Dict]]) -> None:
146 _atomic_write(_REVENUE_PATH, data)
148 # ─── Listing CRUD ──────────────────────────────────────────────────
150 def publish_app(self, owner_id: str, name: str, description: str,
151 recipe_id: str = '', agent_type: str = 'general',
152 tagline: str = '', category: str = 'general',
153 screenshots: List[str] = None, demo_url: str = '',
154 pricing_model: str = 'free', price_spark: int = 0,
155 monthly_price_spark: int = 0, feature_list: List[str] = None,
156 competing_with: List[str] = None,
157 platforms: List[str] = None,
158 distribution_channels: List[str] = None,
159 benchmark_scores: Dict = None,
160 product_id: str = '') -> Dict:
161 """Create a new marketplace listing. Returns the listing dict."""
162 if not owner_id or not name:
163 return {'error': 'owner_id and name are required'}
164 if category not in APP_CATEGORIES:
165 category = 'general'
166 if pricing_model not in PRICING_MODELS:
167 pricing_model = 'free'
169 listing_id = str(uuid.uuid4())
170 now = datetime.utcnow().isoformat()
172 listing = {
173 'listing_id': listing_id,
174 'product_id': product_id or listing_id,
175 'owner_id': owner_id,
176 'name': name,
177 'description': description,
178 'tagline': tagline or '',
179 'category': category,
180 'screenshots': screenshots or [],
181 'demo_url': demo_url,
182 'recipe_id': recipe_id,
183 'agent_type': agent_type,
184 'pricing_model': pricing_model,
185 'price_spark': max(0, price_spark),
186 'monthly_price_spark': max(0, monthly_price_spark),
187 'rating': 0.0,
188 'review_count': 0,
189 'install_count': 0,
190 'feature_list': feature_list or [],
191 'competing_with': competing_with or [],
192 'platforms': [p for p in (platforms or ['web']) if p in SUPPORTED_PLATFORMS],
193 'distribution_channels': distribution_channels or [],
194 'benchmark_scores': benchmark_scores or {},
195 'created_at': now,
196 'updated_at': now,
197 'status': 'active',
198 }
200 with self._lock:
201 listings = self._listings()
202 listings[listing_id] = listing
203 self._save_listings(listings)
205 _emit('marketplace.app.published', {
206 'listing_id': listing_id,
207 'name': name,
208 'owner_id': owner_id,
209 'category': category,
210 })
212 logger.info(f"Marketplace: published '{name}' ({listing_id}) by {owner_id}")
213 return listing
215 def update_app(self, listing_id: str, owner_id: str,
216 updates: Dict) -> Dict:
217 """Update an existing listing. Only the owner can update."""
218 with self._lock:
219 listings = self._listings()
220 listing = listings.get(listing_id)
221 if not listing:
222 return {'error': 'Listing not found'}
223 if listing['owner_id'] != owner_id:
224 return {'error': 'Only the owner can update this listing'}
226 # Whitelist of updatable fields
227 allowed = {
228 'name', 'description', 'tagline', 'category', 'screenshots',
229 'demo_url', 'pricing_model', 'price_spark', 'monthly_price_spark',
230 'feature_list', 'competing_with', 'platforms',
231 'distribution_channels', 'benchmark_scores', 'status',
232 'recipe_id', 'agent_type',
233 }
234 for key, val in updates.items():
235 if key in allowed:
236 if key == 'category' and val not in APP_CATEGORIES:
237 continue
238 if key == 'pricing_model' and val not in PRICING_MODELS:
239 continue
240 listing[key] = val
242 listing['updated_at'] = datetime.utcnow().isoformat()
243 listings[listing_id] = listing
244 self._save_listings(listings)
246 return listing
248 def get_app(self, listing_id: str) -> Optional[Dict]:
249 """Get full listing with reviews."""
250 listings = self._listings()
251 listing = listings.get(listing_id)
252 if not listing or listing.get('status') == 'removed':
253 return None
255 result = dict(listing)
256 reviews = self._reviews()
257 result['reviews'] = reviews.get(listing_id, [])
259 # Find competing apps in same category
260 competitors = []
261 for lid, other in listings.items():
262 if (lid != listing_id
263 and other.get('category') == listing.get('category')
264 and other.get('status') == 'active'):
265 competitors.append({
266 'listing_id': lid,
267 'name': other['name'],
268 'rating': other.get('rating', 0),
269 'install_count': other.get('install_count', 0),
270 })
271 result['competitors'] = sorted(
272 competitors, key=lambda x: x.get('install_count', 0), reverse=True
273 )[:10]
275 return result
277 # ─── Search & Browse ───────────────────────────────────────────────
279 def list_apps(self, category: str = None, query: str = None,
280 sort: str = 'popular', page: int = 1,
281 per_page: int = 20) -> Dict:
282 """List/search apps with pagination.
284 Sort options: popular, newest, rating, name, installs
285 """
286 listings = self._listings()
287 results = []
288 for lid, listing in listings.items():
289 if listing.get('status') != 'active':
290 continue
291 if category and listing.get('category') != category:
292 continue
293 if query:
294 q_lower = query.lower()
295 searchable = ' '.join([
296 listing.get('name', ''),
297 listing.get('description', ''),
298 listing.get('tagline', ''),
299 ' '.join(listing.get('feature_list', [])),
300 ]).lower()
301 if q_lower not in searchable:
302 continue
303 results.append(listing)
305 # Sort
306 sort_keys = {
307 'popular': lambda x: x.get('install_count', 0),
308 'newest': lambda x: x.get('created_at', ''),
309 'rating': lambda x: x.get('rating', 0),
310 'name': lambda x: x.get('name', '').lower(),
311 'installs': lambda x: x.get('install_count', 0),
312 }
313 sort_fn = sort_keys.get(sort, sort_keys['popular'])
314 reverse = sort not in ('name',)
315 results.sort(key=sort_fn, reverse=reverse)
317 # Paginate
318 total = len(results)
319 start = (page - 1) * per_page
320 end = start + per_page
321 page_results = results[start:end]
323 return {
324 'apps': page_results,
325 'total': total,
326 'page': page,
327 'per_page': per_page,
328 'total_pages': max(1, math.ceil(total / per_page)),
329 }
331 def search_apps(self, query: str, filters: Dict = None) -> Dict:
332 """Full-text search with optional filters.
334 Filters: category, pricing_model, min_rating, platform
335 """
336 filters = filters or {}
337 listings = self._listings()
338 results = []
339 q_lower = query.lower()
341 for lid, listing in listings.items():
342 if listing.get('status') != 'active':
343 continue
345 # Text match (name, description, tagline, features, agent_type)
346 searchable = ' '.join([
347 listing.get('name', ''),
348 listing.get('description', ''),
349 listing.get('tagline', ''),
350 listing.get('agent_type', ''),
351 ' '.join(listing.get('feature_list', [])),
352 ]).lower()
353 if q_lower not in searchable:
354 continue
356 # Apply filters
357 if filters.get('category') and listing.get('category') != filters['category']:
358 continue
359 if filters.get('pricing_model') and listing.get('pricing_model') != filters['pricing_model']:
360 continue
361 if filters.get('min_rating') and listing.get('rating', 0) < filters['min_rating']:
362 continue
363 if filters.get('platform'):
364 if filters['platform'] not in listing.get('platforms', []):
365 continue
367 # Score: name match > tagline match > description match
368 score = 0
369 if q_lower in listing.get('name', '').lower():
370 score += 100
371 if q_lower in listing.get('tagline', '').lower():
372 score += 50
373 score += listing.get('install_count', 0) * 0.1
374 score += listing.get('rating', 0) * 10
376 results.append({**listing, '_search_score': score})
378 results.sort(key=lambda x: x.get('_search_score', 0), reverse=True)
380 # Strip internal score
381 for r in results:
382 r.pop('_search_score', None)
384 return {
385 'query': query,
386 'filters': filters,
387 'results': results[:50],
388 'total': len(results),
389 }
391 def compare_apps(self, listing_ids: List[str]) -> Dict:
392 """Side-by-side comparison of multiple apps."""
393 listings = self._listings()
394 apps = []
395 for lid in listing_ids:
396 listing = listings.get(lid)
397 if listing:
398 apps.append(listing)
400 if len(apps) < 2:
401 return {'error': 'Need at least 2 valid listings to compare'}
403 # Build feature union
404 all_features = set()
405 for app in apps:
406 all_features.update(app.get('feature_list', []))
408 comparison = {
409 'apps': [],
410 'feature_matrix': {},
411 'all_features': sorted(all_features),
412 }
414 for app in apps:
415 app_features = set(app.get('feature_list', []))
416 comparison['apps'].append({
417 'listing_id': app['listing_id'],
418 'name': app['name'],
419 'rating': app.get('rating', 0),
420 'install_count': app.get('install_count', 0),
421 'pricing_model': app.get('pricing_model', 'free'),
422 'price_spark': app.get('price_spark', 0),
423 'platforms': app.get('platforms', []),
424 'benchmark_scores': app.get('benchmark_scores', {}),
425 })
426 comparison['feature_matrix'][app['listing_id']] = {
427 feat: (feat in app_features) for feat in all_features
428 }
430 return comparison
432 def get_competing_apps(self, listing_id: str) -> List[Dict]:
433 """Get apps competing in the same category."""
434 listings = self._listings()
435 listing = listings.get(listing_id)
436 if not listing:
437 return []
439 category = listing.get('category', 'general')
440 competitors = []
441 for lid, other in listings.items():
442 if (lid != listing_id
443 and other.get('category') == category
444 and other.get('status') == 'active'):
445 competitors.append({
446 'listing_id': lid,
447 'name': other['name'],
448 'tagline': other.get('tagline', ''),
449 'rating': other.get('rating', 0),
450 'install_count': other.get('install_count', 0),
451 'pricing_model': other.get('pricing_model', 'free'),
452 'price_spark': other.get('price_spark', 0),
453 })
455 competitors.sort(key=lambda x: x.get('install_count', 0), reverse=True)
456 return competitors
458 def get_trending(self, days: int = 7, limit: int = 20) -> List[Dict]:
459 """Most installed apps in the last N days."""
460 cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
461 installs = self._installs()
462 listings = self._listings()
464 # Count recent installs per listing
465 install_counts = {}
466 for lid, records in installs.items():
467 count = sum(
468 1 for r in records
469 if r.get('installed_at', '') >= cutoff
470 )
471 if count > 0:
472 install_counts[lid] = count
474 # Build trending list
475 trending = []
476 for lid, count in sorted(install_counts.items(), key=lambda x: x[1], reverse=True):
477 listing = listings.get(lid)
478 if listing and listing.get('status') == 'active':
479 trending.append({
480 **listing,
481 'recent_installs': count,
482 })
483 if len(trending) >= limit:
484 break
486 return trending
488 def get_categories(self) -> List[Dict]:
489 """All categories with listing counts."""
490 listings = self._listings()
491 counts = {}
492 for lid, listing in listings.items():
493 if listing.get('status') != 'active':
494 continue
495 cat = listing.get('category', 'general')
496 counts[cat] = counts.get(cat, 0) + 1
498 result = []
499 for cat in APP_CATEGORIES:
500 result.append({
501 'category': cat,
502 'count': counts.get(cat, 0),
503 })
504 return result
506 def feature_comparison_matrix(self, category: str) -> Dict:
507 """Matrix of features across all apps in a category."""
508 listings = self._listings()
509 apps_in_cat = []
510 all_features = set()
512 for lid, listing in listings.items():
513 if listing.get('status') != 'active':
514 continue
515 if listing.get('category') != category:
516 continue
517 apps_in_cat.append(listing)
518 all_features.update(listing.get('feature_list', []))
520 sorted_features = sorted(all_features)
521 matrix = {}
522 for app in apps_in_cat:
523 app_features = set(app.get('feature_list', []))
524 matrix[app['listing_id']] = {
525 'name': app['name'],
526 'features': {feat: (feat in app_features) for feat in sorted_features},
527 'rating': app.get('rating', 0),
528 'install_count': app.get('install_count', 0),
529 }
531 return {
532 'category': category,
533 'features': sorted_features,
534 'apps': matrix,
535 'app_count': len(apps_in_cat),
536 }
538 # ─── Install & Rate ────────────────────────────────────────────────
540 def install_app(self, user_id: str, listing_id: str) -> Dict:
541 """Install an app (recipe/agent) for a user.
543 For paid apps, deducts Spark and records revenue with 90/9/1 split.
544 """
545 if not user_id:
546 return {'error': 'user_id is required'}
548 with self._lock:
549 listings = self._listings()
550 listing = listings.get(listing_id)
551 if not listing:
552 return {'error': 'Listing not found'}
553 if listing.get('status') != 'active':
554 return {'error': 'Listing is not active'}
556 # Check if already installed
557 installs = self._installs()
558 user_installs = installs.get(listing_id, [])
559 already = any(r.get('user_id') == user_id for r in user_installs)
560 if already:
561 return {'error': 'Already installed', 'listing_id': listing_id}
563 # Handle payment for paid apps
564 price = listing.get('price_spark', 0)
565 payment_recorded = False
566 if listing.get('pricing_model') in ('paid', 'subscription') and price > 0:
567 payment_result = self._process_payment(
568 user_id, listing['owner_id'], listing_id, price)
569 if payment_result.get('error'):
570 return payment_result
571 payment_recorded = True
573 # Record install
574 install_record = {
575 'user_id': user_id,
576 'listing_id': listing_id,
577 'installed_at': datetime.utcnow().isoformat(),
578 'paid': payment_recorded,
579 'price_spark': price if payment_recorded else 0,
580 }
581 user_installs.append(install_record)
582 installs[listing_id] = user_installs
584 # Increment install count
585 listing['install_count'] = listing.get('install_count', 0) + 1
586 listings[listing_id] = listing
588 self._save_installs(installs)
589 self._save_listings(listings)
591 # Wire up the recipe for the user (non-blocking)
592 recipe_id = listing.get('recipe_id', '')
593 if recipe_id:
594 self._wire_recipe_for_user(user_id, recipe_id, listing_id)
596 _emit('marketplace.app.installed', {
597 'listing_id': listing_id,
598 'user_id': user_id,
599 'name': listing.get('name', ''),
600 'paid': payment_recorded,
601 })
603 logger.info(f"Marketplace: user {user_id} installed '{listing.get('name')}' ({listing_id})")
604 return {
605 'success': True,
606 'listing_id': listing_id,
607 'name': listing.get('name', ''),
608 'recipe_id': recipe_id,
609 'paid': payment_recorded,
610 }
612 def rate_app(self, user_id: str, listing_id: str,
613 rating: float, review: str = '') -> Dict:
614 """Rate and review an app (1-5 stars). One review per user per app."""
615 if not user_id:
616 return {'error': 'user_id is required'}
617 if rating < 1 or rating > 5:
618 return {'error': 'Rating must be between 1 and 5'}
620 with self._lock:
621 reviews = self._reviews()
622 listing_reviews = reviews.get(listing_id, [])
624 # Check if user already reviewed
625 for existing in listing_reviews:
626 if existing.get('user_id') == user_id:
627 # Update existing review
628 existing['rating'] = rating
629 existing['review'] = review
630 existing['updated_at'] = datetime.utcnow().isoformat()
631 reviews[listing_id] = listing_reviews
632 self._save_reviews(reviews)
633 self._update_listing_rating(listing_id, listing_reviews)
634 return {'success': True, 'updated': True}
636 # New review
637 review_record = {
638 'review_id': str(uuid.uuid4()),
639 'user_id': user_id,
640 'listing_id': listing_id,
641 'rating': rating,
642 'review': review,
643 'created_at': datetime.utcnow().isoformat(),
644 }
645 listing_reviews.append(review_record)
646 reviews[listing_id] = listing_reviews
647 self._save_reviews(reviews)
648 self._update_listing_rating(listing_id, listing_reviews)
650 _emit('marketplace.app.reviewed', {
651 'listing_id': listing_id,
652 'user_id': user_id,
653 'rating': rating,
654 })
656 return {'success': True, 'review_id': review_record['review_id']}
658 def _update_listing_rating(self, listing_id: str, reviews: List[Dict]) -> None:
659 """Recalculate average rating for a listing. Must hold self._lock."""
660 listings = self._listings()
661 listing = listings.get(listing_id)
662 if not listing:
663 return
665 ratings = [r['rating'] for r in reviews if 'rating' in r]
666 if ratings:
667 listing['rating'] = round(sum(ratings) / len(ratings), 2)
668 listing['review_count'] = len(ratings)
669 else:
670 listing['rating'] = 0.0
671 listing['review_count'] = 0
673 listings[listing_id] = listing
674 self._save_listings(listings)
676 # ─── Revenue ───────────────────────────────────────────────────────
678 def _process_payment(self, buyer_id: str, seller_id: str,
679 listing_id: str, amount_spark: int) -> Dict:
680 """Process Spark payment with 90/9/1 split.
682 90% to creator, 9% to infrastructure, 1% to central.
683 Uses ResonanceService.award_spark when available.
685 Failed payments are persisted to revenue.json with status='pending'
686 so the reconciliation job (see ``reconcile_pending_payments``) can
687 retry them later. Before this guard a failed ResonanceService call
688 was silently dropped and the install was aborted without leaving
689 an audit trail.
690 """
691 creator_share = int(amount_spark * REVENUE_SPLIT_USERS)
692 infra_share = int(amount_spark * REVENUE_SPLIT_INFRA)
693 central_share = amount_spark - creator_share - infra_share # remainder
695 # Try to deduct from buyer and credit seller via ResonanceService
696 try:
697 from integrations.social.models import db_session
698 from integrations.social.resonance_engine import ResonanceService
699 with db_session() as db:
700 ResonanceService.award_spark(
701 db, buyer_id, -amount_spark,
702 'marketplace_purchase', listing_id,
703 f'App purchase: {listing_id}')
704 ResonanceService.award_spark(
705 db, seller_id, creator_share,
706 'marketplace_sale', listing_id,
707 f'App sale revenue (90%): {listing_id}')
708 except ImportError:
709 logger.debug("ResonanceService not available, recording payment ledger only")
710 except Exception as e:
711 logger.warning("Payment processing failed: %s", e)
712 # Record the failed payment so reconciliation can retry it.
713 self._record_payment(
714 seller_id, buyer_id, listing_id, amount_spark,
715 creator_share, infra_share, central_share,
716 status='pending', error=str(e), retry_count=0)
717 return {'error': f'Payment failed: {e}'}
719 # Record successful payment in revenue ledger
720 self._record_payment(
721 seller_id, buyer_id, listing_id, amount_spark,
722 creator_share, infra_share, central_share,
723 status='settled', error='', retry_count=0)
725 return {'success': True, 'creator_share': creator_share}
727 def _record_payment(self, seller_id: str, buyer_id: str,
728 listing_id: str, amount_spark: int,
729 creator_share: int, infra_share: int,
730 central_share: int, *, status: str = 'settled',
731 error: str = '', retry_count: int = 0) -> None:
732 """Append a payment record to revenue.json (thread-safe).
734 ``status`` is 'settled' on success and 'pending' when the Spark
735 transfer failed and needs reconciliation. This is the single writer
736 for the revenue ledger — all payment state flows through here so
737 the reconciliation job can trust the schema.
738 """
739 with self._lock:
740 revenue = self._revenue()
741 seller_revenue = revenue.get(seller_id, [])
742 seller_revenue.append({
743 'listing_id': listing_id,
744 'buyer_id': buyer_id,
745 'total_spark': amount_spark,
746 'creator_share': creator_share,
747 'infra_share': infra_share,
748 'central_share': central_share,
749 'timestamp': datetime.utcnow().isoformat(),
750 'status': status,
751 'error': error,
752 'retry_count': retry_count,
753 })
754 revenue[seller_id] = seller_revenue
755 self._save_revenue(revenue)
757 def reconcile_pending_payments(
758 self, stale_after_minutes: int = 15,
759 ) -> Dict[str, int]:
760 """Scan revenue.json for pending payments older than the threshold
761 and retry them through the existing payment path.
763 Called by the agent_daemon tick on a slow cadence (every few min).
764 Respects a retry_count cap so a truly-broken payment can't loop
765 forever — after 5 retries the row is marked 'failed' for human
766 review.
768 Returns a dict of counters: ``scanned``, ``retried``, ``settled``,
769 ``failed``, ``skipped``.
770 """
771 from integrations.social.models import db_session
772 from integrations.social.resonance_engine import ResonanceService
773 cutoff = datetime.utcnow() - timedelta(minutes=stale_after_minutes)
774 counters = {'scanned': 0, 'retried': 0, 'settled': 0,
775 'failed': 0, 'skipped': 0}
776 MAX_RETRIES = 5
777 with self._lock:
778 revenue = self._revenue()
779 changed = False
780 for seller_id, rows in list(revenue.items()):
781 for row in rows:
782 counters['scanned'] += 1
783 if row.get('status') != 'pending':
784 continue
785 ts_str = row.get('timestamp', '')
786 try:
787 ts = datetime.fromisoformat(ts_str)
788 except (TypeError, ValueError):
789 counters['skipped'] += 1
790 continue
791 if ts >= cutoff:
792 counters['skipped'] += 1
793 continue
794 retry_count = int(row.get('retry_count', 0))
795 if retry_count >= MAX_RETRIES:
796 row['status'] = 'failed'
797 changed = True
798 counters['failed'] += 1
799 logger.critical(
800 "Marketplace reconcile: payment marked FAILED "
801 "after %d retries — listing=%s seller=%s "
802 "buyer=%s amount=%s last_error=%s",
803 retry_count, row.get('listing_id'), seller_id,
804 row.get('buyer_id'), row.get('total_spark'),
805 row.get('error', ''))
806 continue
807 # Attempt the retry via the canonical gateway
808 counters['retried'] += 1
809 amount = int(row.get('total_spark', 0))
810 creator_share = int(row.get('creator_share', 0))
811 buyer_id = row.get('buyer_id', '')
812 listing_id = row.get('listing_id', '')
813 try:
814 with db_session() as db:
815 ResonanceService.award_spark(
816 db, buyer_id, -amount,
817 'marketplace_purchase_retry', listing_id,
818 f'App purchase retry: {listing_id}')
819 ResonanceService.award_spark(
820 db, seller_id, creator_share,
821 'marketplace_sale_retry', listing_id,
822 f'App sale retry (90%): {listing_id}')
823 row['status'] = 'settled'
824 row['error'] = ''
825 row['retry_count'] = retry_count + 1
826 row['settled_at'] = datetime.utcnow().isoformat()
827 counters['settled'] += 1
828 changed = True
829 logger.info(
830 "Marketplace reconcile: settled pending payment "
831 "for listing=%s seller=%s amount=%s",
832 listing_id, seller_id, amount)
833 except Exception as e:
834 row['retry_count'] = retry_count + 1
835 row['error'] = str(e)
836 row['last_retry_at'] = datetime.utcnow().isoformat()
837 changed = True
838 logger.warning(
839 "Marketplace reconcile: retry failed for "
840 "listing=%s seller=%s (attempt %d): %s",
841 listing_id, seller_id, retry_count + 1, e)
842 if changed:
843 self._save_revenue(revenue)
844 return counters
846 def get_revenue_report(self, owner_id: str) -> Dict:
847 """Earnings report for an app creator."""
848 revenue = self._revenue()
849 owner_records = revenue.get(owner_id, [])
850 listings = self._listings()
852 # Aggregate per app
853 per_app = {}
854 total_earned = 0
855 for record in owner_records:
856 lid = record.get('listing_id', '')
857 if lid not in per_app:
858 listing = listings.get(lid, {})
859 per_app[lid] = {
860 'listing_id': lid,
861 'name': listing.get('name', 'Unknown'),
862 'total_sales': 0,
863 'total_spark_earned': 0,
864 'total_gross': 0,
865 }
866 per_app[lid]['total_sales'] += 1
867 per_app[lid]['total_spark_earned'] += record.get('creator_share', 0)
868 per_app[lid]['total_gross'] += record.get('total_spark', 0)
869 total_earned += record.get('creator_share', 0)
871 # Count owned listings
872 owned_apps = []
873 for lid, listing in listings.items():
874 if listing.get('owner_id') == owner_id:
875 owned_apps.append({
876 'listing_id': lid,
877 'name': listing.get('name', ''),
878 'install_count': listing.get('install_count', 0),
879 'rating': listing.get('rating', 0),
880 'pricing_model': listing.get('pricing_model', 'free'),
881 })
883 return {
884 'owner_id': owner_id,
885 'total_spark_earned': total_earned,
886 'total_sales': sum(a['total_sales'] for a in per_app.values()),
887 'revenue_split': f'{int(REVENUE_SPLIT_USERS * 100)}% creator / '
888 f'{int(REVENUE_SPLIT_INFRA * 100)}% infra / '
889 f'{int(REVENUE_SPLIT_CENTRAL * 100)}% central',
890 'per_app': list(per_app.values()),
891 'owned_apps': owned_apps,
892 }
894 # ─── Recipe wiring ─────────────────────────────────────────────────
896 def _wire_recipe_for_user(self, user_id: str, recipe_id: str,
897 listing_id: str) -> None:
898 """Wire an installed recipe so the user can invoke it via REUSE mode.
900 Copies recipe reference into the user's prompt directory so
901 /chat with the matching prompt_id triggers REUSE.
902 """
903 try:
904 import glob, re
905 # Sanitize recipe_id to prevent path traversal
906 recipe_id = re.sub(r'[^a-zA-Z0-9_\-]', '', recipe_id)
907 if not recipe_id:
908 return
909 pattern = os.path.join('prompts', f'*{recipe_id}*_recipe.json')
910 matches = glob.glob(pattern)
911 if not matches:
912 logger.debug(f"No recipe file found for {recipe_id}")
913 return
915 # Create user-specific prompt reference
916 user_prompt_path = os.path.join(
917 'prompts', f'marketplace_{user_id}_{listing_id}.json')
918 prompt_data = {
919 'prompt_id': f'marketplace_{listing_id}',
920 'user_id': user_id,
921 'source_recipe': matches[0],
922 'listing_id': listing_id,
923 'installed_at': datetime.utcnow().isoformat(),
924 }
925 with open(user_prompt_path, 'w', encoding='utf-8') as f:
926 json.dump(prompt_data, f, indent=2)
927 logger.debug(f"Wired recipe {recipe_id} for user {user_id}")
928 except Exception as e:
929 logger.debug(f"Recipe wiring failed: {e}")
931 # ─── Channel Distribution ──────────────────────────────────────────
933 def distribute_to_channel(self, listing_id: str, channel: str) -> Dict:
934 """Deploy app as bot to a distribution channel.
936 Dispatches a goal to create the channel bot/integration.
937 Supported channels: discord, telegram, slack, matrix, whatsapp, web, etc.
938 """
939 listings = self._listings()
940 listing = listings.get(listing_id)
941 if not listing:
942 return {'error': 'Listing not found'}
944 prompt = (
945 f"Deploy the HART OS app '{listing['name']}' as a bot/integration "
946 f"on the {channel} channel. The app description: {listing['description']}. "
947 f"Recipe ID: {listing.get('recipe_id', 'none')}. "
948 f"Set up the channel adapter, register commands, and make the app "
949 f"accessible to users on {channel}."
950 )
952 try:
953 from integrations.agent_engine.dispatch import dispatch_goal
954 response = dispatch_goal(
955 prompt=prompt,
956 user_id=listing['owner_id'],
957 goal_id=f'distribute_{listing_id}_{channel}',
958 goal_type='distribution',
959 )
960 # Track distribution
961 with self._lock:
962 listings = self._listings()
963 listing = listings.get(listing_id, {})
964 channels = listing.get('distribution_channels', [])
965 if channel not in channels:
966 channels.append(channel)
967 listing['distribution_channels'] = channels
968 listings[listing_id] = listing
969 self._save_listings(listings)
971 return {
972 'success': True,
973 'listing_id': listing_id,
974 'channel': channel,
975 'dispatch_response': response,
976 }
977 except Exception as e:
978 logger.warning(f"Channel distribution failed: {e}")
979 return {'error': f'Distribution failed: {e}'}
981 # ─── Cross-Platform Distribution (Manifest Generation) ─────────────
983 def distribute_to_google_play(self, listing_id: str) -> Dict:
984 """Generate Android manifest for Hevolve React Native wrapper."""
985 listing = self._listings().get(listing_id)
986 if not listing:
987 return {'error': 'Listing not found'}
989 manifest = {
990 'format': 'android_manifest',
991 'package': f'com.hevolve.hartos.{_safe_id(listing["name"])}',
992 'versionCode': 1,
993 'versionName': '1.0.0',
994 'minSdkVersion': 24,
995 'targetSdkVersion': 34,
996 'application': {
997 'label': listing['name'],
998 'description': listing.get('description', ''),
999 'icon': '@mipmap/ic_launcher',
1000 'theme': '@style/HevolveTheme',
1001 },
1002 'permissions': [
1003 'android.permission.INTERNET',
1004 'android.permission.ACCESS_NETWORK_STATE',
1005 ],
1006 'hartos_config': {
1007 'listing_id': listing_id,
1008 'recipe_id': listing.get('recipe_id', ''),
1009 'api_endpoint': '/chat',
1010 'agent_type': listing.get('agent_type', 'general'),
1011 },
1012 'react_native': {
1013 'wrapper': 'hevolve-rn-shell',
1014 'webview_fallback': True,
1015 'deep_link_scheme': f'hartos-{_safe_id(listing["name"])}',
1016 },
1017 }
1018 return {'success': True, 'platform': 'google_play', 'manifest': manifest}
1020 def distribute_to_microsoft_store(self, listing_id: str) -> Dict:
1021 """Generate MSIX/AppX manifest for Windows wrapper."""
1022 listing = self._listings().get(listing_id)
1023 if not listing:
1024 return {'error': 'Listing not found'}
1026 manifest = {
1027 'format': 'appx_manifest',
1028 'Identity': {
1029 'Name': f'Hevolve.HARTOS.{_safe_id(listing["name"])}',
1030 'Publisher': 'CN=Hevolve',
1031 'Version': '1.0.0.0',
1032 },
1033 'Properties': {
1034 'DisplayName': listing['name'],
1035 'Description': listing.get('description', ''),
1036 'PublisherDisplayName': 'Hevolve',
1037 'Logo': 'Assets\\StoreLogo.png',
1038 },
1039 'Dependencies': {
1040 'TargetDeviceFamily': {
1041 'Name': 'Windows.Desktop',
1042 'MinVersion': '10.0.17763.0',
1043 },
1044 },
1045 'Applications': [{
1046 'Id': _safe_id(listing['name']),
1047 'Executable': 'hartos-shell.exe',
1048 'EntryPoint': 'Windows.FullTrustApplication',
1049 }],
1050 'hartos_config': {
1051 'listing_id': listing_id,
1052 'recipe_id': listing.get('recipe_id', ''),
1053 'api_endpoint': '/chat',
1054 },
1055 }
1056 return {'success': True, 'platform': 'microsoft_store', 'manifest': manifest}
1058 def distribute_to_apple_store(self, listing_id: str) -> Dict:
1059 """Generate iOS manifest for Apple wrapper."""
1060 listing = self._listings().get(listing_id)
1061 if not listing:
1062 return {'error': 'Listing not found'}
1064 manifest = {
1065 'format': 'ios_plist',
1066 'CFBundleIdentifier': f'com.hevolve.hartos.{_safe_id(listing["name"])}',
1067 'CFBundleName': listing['name'],
1068 'CFBundleShortVersionString': '1.0.0',
1069 'CFBundleVersion': '1',
1070 'MinimumOSVersion': '15.0',
1071 'UIDeviceFamily': [1, 2], # iPhone + iPad
1072 'UIRequiredDeviceCapabilities': ['arm64'],
1073 'NSAppTransportSecurity': {
1074 'NSAllowsArbitraryLoads': False,
1075 'NSExceptionDomains': {
1076 'hartos.local': {'NSTemporaryExceptionAllowsInsecureHTTPLoads': True},
1077 },
1078 },
1079 'hartos_config': {
1080 'listing_id': listing_id,
1081 'recipe_id': listing.get('recipe_id', ''),
1082 'api_endpoint': '/chat',
1083 },
1084 }
1085 return {'success': True, 'platform': 'apple_store', 'manifest': manifest}
1087 def distribute_to_web(self, listing_id: str) -> Dict:
1088 """Generate PWA manifest for web distribution."""
1089 listing = self._listings().get(listing_id)
1090 if not listing:
1091 return {'error': 'Listing not found'}
1093 manifest = {
1094 'format': 'pwa_manifest',
1095 'name': listing['name'],
1096 'short_name': listing['name'][:12],
1097 'description': listing.get('description', ''),
1098 'start_url': f'/app/{listing_id}',
1099 'display': 'standalone',
1100 'background_color': '#0D1117',
1101 'theme_color': '#58A6FF',
1102 'icons': [
1103 {'src': '/icons/icon-192.png', 'sizes': '192x192', 'type': 'image/png'},
1104 {'src': '/icons/icon-512.png', 'sizes': '512x512', 'type': 'image/png'},
1105 ],
1106 'categories': [listing.get('category', 'utilities')],
1107 'hartos_config': {
1108 'listing_id': listing_id,
1109 'recipe_id': listing.get('recipe_id', ''),
1110 'api_endpoint': '/chat',
1111 },
1112 }
1113 return {'success': True, 'platform': 'web', 'manifest': manifest}
1115 def distribute_to_flatpak(self, listing_id: str) -> Dict:
1116 """Generate Flatpak manifest for Linux distribution."""
1117 listing = self._listings().get(listing_id)
1118 if not listing:
1119 return {'error': 'Listing not found'}
1121 app_id = f'com.hevolve.hartos.{_safe_id(listing["name"])}'
1122 manifest = {
1123 'format': 'flatpak_manifest',
1124 'app-id': app_id,
1125 'runtime': 'org.freedesktop.Platform',
1126 'runtime-version': '23.08',
1127 'sdk': 'org.freedesktop.Sdk',
1128 'command': 'hartos-shell',
1129 'finish-args': [
1130 '--share=network',
1131 '--share=ipc',
1132 '--socket=fallback-x11',
1133 '--socket=wayland',
1134 '--device=dri',
1135 ],
1136 'modules': [{
1137 'name': 'hartos-agent-wrapper',
1138 'buildsystem': 'simple',
1139 'build-commands': ['install -D hartos-shell /app/bin/hartos-shell'],
1140 }],
1141 'hartos_config': {
1142 'listing_id': listing_id,
1143 'recipe_id': listing.get('recipe_id', ''),
1144 'api_endpoint': '/chat',
1145 },
1146 }
1147 return {'success': True, 'platform': 'flatpak', 'manifest': manifest}
1150# ═══════════════════════════════════════════════════════════════════════════
1151# AppPromotionAgent — Auto-marketing engine
1152# ═══════════════════════════════════════════════════════════════════════════
1154class AppPromotionAgent:
1155 """Auto-marketing engine for marketplace listings.
1157 When a user publishes an app, this agent automatically:
1158 - Generates marketing content and SEO keywords
1159 - Distributes to relevant channels
1160 - Onboards new users with tutorials
1161 - Runs benchmark comparisons against competitors
1162 """
1164 def __init__(self, marketplace: AppMarketplace):
1165 self._marketplace = marketplace
1167 def auto_promote(self, listing_id: str) -> Dict:
1168 """Create automatic marketing campaign for a listing.
1170 1. Generate marketing content (description, keywords, comparison hooks)
1171 2. Post to platform feed
1172 3. Distribute to channels matching app category
1173 4. Schedule periodic re-promotion
1174 """
1175 listing = self._marketplace._listings().get(listing_id)
1176 if not listing:
1177 return {'error': 'Listing not found'}
1179 results = {
1180 'listing_id': listing_id,
1181 'name': listing['name'],
1182 'actions': [],
1183 }
1185 # 1. Generate SEO keywords and marketing copy
1186 keywords = self._generate_keywords(listing)
1187 results['generated_keywords'] = keywords
1189 # 2. Post to platform social feed
1190 feed_result = self._post_to_feed(listing)
1191 results['actions'].append({'type': 'feed_post', 'result': feed_result})
1193 # 3. Distribute to category-matched channels
1194 channel_map = {
1195 'coding': ['discord', 'slack', 'matrix'],
1196 'productivity': ['slack', 'telegram', 'web'],
1197 'marketing': ['telegram', 'twitter', 'linkedin'],
1198 'finance': ['telegram', 'discord', 'web'],
1199 'education': ['telegram', 'discord', 'web'],
1200 'entertainment': ['discord', 'telegram', 'whatsapp'],
1201 'research': ['slack', 'matrix', 'discord'],
1202 'design': ['discord', 'slack', 'web'],
1203 'social': ['telegram', 'whatsapp', 'discord'],
1204 }
1205 category = listing.get('category', 'general')
1206 channels = channel_map.get(category, ['web', 'telegram'])
1207 for channel in channels:
1208 dist_result = self._marketplace.distribute_to_channel(listing_id, channel)
1209 results['actions'].append({
1210 'type': 'channel_distribution',
1211 'channel': channel,
1212 'result': 'dispatched' if dist_result.get('success') else dist_result.get('error', 'failed'),
1213 })
1215 # 4. Create thought experiment for comparison
1216 thought_result = self._create_thought_experiment(listing)
1217 results['actions'].append({'type': 'thought_experiment', 'result': thought_result})
1219 # 5. Schedule re-promotion goal
1220 repromo_result = self._schedule_repromotion(listing_id)
1221 results['actions'].append({'type': 'repromotion_scheduled', 'result': repromo_result})
1223 logger.info(f"Marketplace promotion: '{listing['name']}' — "
1224 f"{len(results['actions'])} actions taken")
1225 return results
1227 def auto_onboard_users(self, listing_id: str, user_id: str) -> Dict:
1228 """Onboard a user who just installed an app.
1230 1. Send welcome message with quick-start tutorial
1231 2. Verify recipe is wired
1232 3. Track engagement start
1233 """
1234 listing = self._marketplace._listings().get(listing_id)
1235 if not listing:
1236 return {'error': 'Listing not found'}
1238 results = {'listing_id': listing_id, 'user_id': user_id, 'actions': []}
1240 # 1. Send welcome/tutorial message
1241 welcome_msg = (
1242 f"Welcome to {listing['name']}! {listing.get('tagline', '')}\n\n"
1243 f"Quick start:\n"
1244 f"- Just type your request naturally\n"
1245 f"- The agent uses a trained recipe for fast, reliable results\n"
1246 )
1247 if listing.get('feature_list'):
1248 welcome_msg += f"- Features: {', '.join(listing['feature_list'][:5])}\n"
1250 try:
1251 from integrations.agent_engine.dispatch import dispatch_goal
1252 dispatch_goal(
1253 prompt=f"Send this welcome message to user {user_id}: {welcome_msg}",
1254 user_id=user_id,
1255 goal_id=f'onboard_{listing_id}_{user_id}',
1256 goal_type='onboarding',
1257 )
1258 results['actions'].append({'type': 'welcome_sent', 'success': True})
1259 except Exception as e:
1260 results['actions'].append({'type': 'welcome_sent', 'success': False, 'error': str(e)})
1262 return results
1264 def auto_compete(self, listing_id: str) -> Dict:
1265 """Find competing apps and generate comparison content."""
1266 competitors = self._marketplace.get_competing_apps(listing_id)
1267 listing = self._marketplace._listings().get(listing_id)
1268 if not listing:
1269 return {'error': 'Listing not found'}
1271 if not competitors:
1272 return {
1273 'listing_id': listing_id,
1274 'message': 'No competitors found — first mover in category',
1275 }
1277 comparison_ids = [listing_id] + [c['listing_id'] for c in competitors[:4]]
1278 comparison = self._marketplace.compare_apps(comparison_ids)
1280 return {
1281 'listing_id': listing_id,
1282 'name': listing['name'],
1283 'competitor_count': len(competitors),
1284 'comparison': comparison,
1285 }
1287 def run_benchmark_comparison(self, listing_ids: List[str]) -> Dict:
1288 """Run the same benchmark task on competing apps, publish results.
1290 Dispatches a benchmark goal for each app and collects scores.
1291 """
1292 results = {'benchmarks': [], 'timestamp': datetime.utcnow().isoformat()}
1293 listings = self._marketplace._listings()
1295 for lid in listing_ids:
1296 listing = listings.get(lid)
1297 if not listing:
1298 continue
1300 recipe_id = listing.get('recipe_id', '')
1301 if not recipe_id:
1302 results['benchmarks'].append({
1303 'listing_id': lid,
1304 'name': listing['name'],
1305 'status': 'skipped',
1306 'reason': 'no recipe_id',
1307 })
1308 continue
1310 # Dispatch benchmark via agent engine
1311 try:
1312 from integrations.agent_engine.dispatch import dispatch_goal
1313 response = dispatch_goal(
1314 prompt=(
1315 f"Benchmark the app '{listing['name']}' (recipe: {recipe_id}). "
1316 f"Run a standard task and measure: response time, accuracy, "
1317 f"completeness, user satisfaction proxy."
1318 ),
1319 user_id='benchmark_agent',
1320 goal_id=f'benchmark_{lid}',
1321 goal_type='benchmark',
1322 )
1323 results['benchmarks'].append({
1324 'listing_id': lid,
1325 'name': listing['name'],
1326 'status': 'dispatched',
1327 'response': response,
1328 })
1329 except Exception as e:
1330 results['benchmarks'].append({
1331 'listing_id': lid,
1332 'name': listing['name'],
1333 'status': 'failed',
1334 'error': str(e),
1335 })
1337 return results
1339 # ─── Private promotion helpers ─────────────────────────────────────
1341 def _generate_keywords(self, listing: Dict) -> List[str]:
1342 """Generate SEO keywords from listing metadata."""
1343 words = set()
1344 for field in ('name', 'description', 'tagline', 'category', 'agent_type'):
1345 text = listing.get(field, '')
1346 if text:
1347 for word in text.lower().split():
1348 cleaned = word.strip('.,!?()[]{}":;')
1349 if len(cleaned) > 3:
1350 words.add(cleaned)
1351 # Add category and features
1352 words.add(listing.get('category', ''))
1353 for feat in listing.get('feature_list', []):
1354 words.add(feat.lower())
1355 return sorted(words)[:30]
1357 def _post_to_feed(self, listing: Dict) -> str:
1358 """Post app announcement to platform social feed."""
1359 try:
1360 from integrations.social.models import db_session
1361 from integrations.social.services import PostService
1362 with db_session() as db:
1363 PostService.create_post(
1364 db,
1365 author_id=listing['owner_id'],
1366 body=(
1367 f"New on HART OS Marketplace: {listing['name']}\n\n"
1368 f"{listing.get('tagline', listing.get('description', '')[:200])}\n\n"
1369 f"Category: {listing.get('category', 'general')}\n"
1370 f"Pricing: {listing.get('pricing_model', 'free')}\n"
1371 f"Install it now from the App Marketplace!"
1372 ),
1373 visibility='public',
1374 )
1375 return 'posted'
1376 except ImportError:
1377 return 'social_service_unavailable'
1378 except Exception as e:
1379 return f'feed_error: {e}'
1381 def _create_thought_experiment(self, listing: Dict) -> str:
1382 """Create a thought experiment comparing approaches to the app's problem."""
1383 try:
1384 from integrations.social.thought_experiment_service import ThoughtExperimentService
1385 from integrations.social.models import db_session
1386 with db_session() as db:
1387 ThoughtExperimentService.create_experiment(
1388 db,
1389 author_id=listing['owner_id'],
1390 title=f"Which approach solves {listing.get('category', 'this')} tasks better?",
1391 hypothesis=(
1392 f"'{listing['name']}' offers a unique approach: "
1393 f"{listing.get('description', '')[:300]}. "
1394 f"How does this compare to existing solutions?"
1395 ),
1396 intent_category='technology',
1397 )
1398 return 'experiment_created'
1399 except ImportError:
1400 return 'thought_experiment_service_unavailable'
1402 def _schedule_repromotion(self, listing_id: str) -> str:
1403 """Schedule periodic re-promotion based on performance."""
1404 try:
1405 from integrations.agent_engine.dispatch import dispatch_goal
1406 dispatch_goal(
1407 prompt=(
1408 f"Re-promote marketplace app {listing_id}. "
1409 f"Check current install count and rating. "
1410 f"If growth is stalling, create fresh marketing content "
1411 f"and redistribute to new channels."
1412 ),
1413 user_id='marketplace_promoter',
1414 goal_id=f'repromo_{listing_id}_{int(time.time())}',
1415 goal_type='marketing',
1416 )
1417 return 'scheduled'
1418 except Exception as e:
1419 return f'schedule_error: {e}'
1422# ═══════════════════════════════════════════════════════════════════════════
1423# Helpers
1424# ═══════════════════════════════════════════════════════════════════════════
1426def _safe_id(name: str) -> str:
1427 """Convert a name to a safe identifier (lowercase, alphanumeric + underscore)."""
1428 return ''.join(c if c.isalnum() else '_' for c in name.lower()).strip('_')[:64]
1431# ═══════════════════════════════════════════════════════════════════════════
1432# Singleton
1433# ═══════════════════════════════════════════════════════════════════════════
1435_marketplace: Optional[AppMarketplace] = None
1436_promotion_agent: Optional[AppPromotionAgent] = None
1439def get_marketplace() -> AppMarketplace:
1440 """Get the global AppMarketplace singleton."""
1441 global _marketplace
1442 if _marketplace is None:
1443 _marketplace = AppMarketplace()
1444 return _marketplace
1447def get_promotion_agent() -> AppPromotionAgent:
1448 """Get the global AppPromotionAgent singleton."""
1449 global _promotion_agent
1450 if _promotion_agent is None:
1451 _promotion_agent = AppPromotionAgent(get_marketplace())
1452 return _promotion_agent
1455# ═══════════════════════════════════════════════════════════════════════════
1456# Goal Seed — Auto-Promoter
1457# ═══════════════════════════════════════════════════════════════════════════
1459SEED_APP_MARKETPLACE_PROMOTER = {
1460 'slug': 'bootstrap_app_marketplace_promoter',
1461 'goal_type': 'marketing',
1462 'title': 'App Marketplace Auto-Promoter',
1463 'description': (
1464 'Continuously monitor new app listings on the HART OS Marketplace. '
1465 'For every new listing: '
1466 '1) Auto-generate marketing content — description polish, SEO keywords, comparison hooks, '
1467 '2) Distribute to channels matching the app category (Discord for coding, Telegram for finance, etc.), '
1468 '3) Run benchmark comparisons against competing apps in the same category, '
1469 '4) Onboard new users with welcome messages and quick-start tutorials, '
1470 '5) Schedule periodic re-promotion for apps with stalling growth, '
1471 '6) Create thought experiments: "Which app solves X better?" to drive organic discussion. '
1472 'Make every app discoverable. 90% of revenue flows to creators.'
1473 ),
1474 'config': {
1475 'autonomous': True,
1476 'continuous': True,
1477 'bootstrap_slug': 'bootstrap_app_marketplace_promoter',
1478 },
1479 'spark_budget': 500,
1480 'use_product': True,
1481}
1484# ═══════════════════════════════════════════════════════════════════════════
1485# Flask Blueprint
1486# ═══════════════════════════════════════════════════════════════════════════
1488from flask import Blueprint, jsonify, request
1490marketplace_bp = Blueprint('marketplace', __name__)
1493@marketplace_bp.route('/api/marketplace/apps', methods=['GET'])
1494def list_apps():
1495 """List/search marketplace apps.
1497 Query params:
1498 category: filter by category
1499 q: search query
1500 sort: popular|newest|rating|name|installs (default: popular)
1501 page: page number (default: 1)
1502 per_page: items per page (default: 20, max: 50)
1503 """
1504 mp = get_marketplace()
1505 category = request.args.get('category')
1506 query = request.args.get('q', '').strip()
1507 sort = request.args.get('sort', 'popular')
1508 page = max(1, request.args.get('page', 1, type=int))
1509 per_page = min(50, max(1, request.args.get('per_page', 20, type=int)))
1511 if query:
1512 # Full-text search with optional filters
1513 filters = {}
1514 if category:
1515 filters['category'] = category
1516 pricing = request.args.get('pricing_model')
1517 if pricing:
1518 filters['pricing_model'] = pricing
1519 min_rating = request.args.get('min_rating', type=float)
1520 if min_rating:
1521 filters['min_rating'] = min_rating
1522 platform = request.args.get('platform')
1523 if platform:
1524 filters['platform'] = platform
1526 result = mp.search_apps(query, filters)
1527 return jsonify({'success': True, **result})
1529 result = mp.list_apps(category=category, sort=sort, page=page, per_page=per_page)
1530 return jsonify({'success': True, **result})
1533@marketplace_bp.route('/api/marketplace/apps/<listing_id>', methods=['GET'])
1534def get_app(listing_id):
1535 """Get full app details with reviews and competitors."""
1536 mp = get_marketplace()
1537 app = mp.get_app(listing_id)
1538 if not app:
1539 return jsonify({'success': False, 'error': 'Listing not found'}), 404
1540 return jsonify({'success': True, 'app': app})
1543@marketplace_bp.route('/api/marketplace/apps', methods=['POST'])
1544def publish_app():
1545 """Publish a new app to the marketplace.
1547 Body (JSON):
1548 owner_id: str (required)
1549 name: str (required)
1550 description: str (required)
1551 recipe_id: str
1552 agent_type: str
1553 tagline: str
1554 category: str
1555 pricing_model: free|freemium|paid|subscription
1556 price_spark: int
1557 monthly_price_spark: int
1558 feature_list: list[str]
1559 platforms: list[str]
1560 screenshots: list[str]
1561 demo_url: str
1562 distribution_channels: list[str]
1563 benchmark_scores: dict
1564 """
1565 data = request.get_json(force=True)
1566 owner_id = data.get('owner_id', '')
1567 name = data.get('name', '').strip()
1568 description = data.get('description', '').strip()
1570 if not owner_id or not name or not description:
1571 return jsonify({
1572 'success': False,
1573 'error': 'owner_id, name, and description are required',
1574 }), 400
1576 mp = get_marketplace()
1577 result = mp.publish_app(
1578 owner_id=owner_id,
1579 name=name,
1580 description=description,
1581 recipe_id=data.get('recipe_id', ''),
1582 agent_type=data.get('agent_type', 'general'),
1583 tagline=data.get('tagline', ''),
1584 category=data.get('category', 'general'),
1585 screenshots=data.get('screenshots', []),
1586 demo_url=data.get('demo_url', ''),
1587 pricing_model=data.get('pricing_model', 'free'),
1588 price_spark=data.get('price_spark', 0),
1589 monthly_price_spark=data.get('monthly_price_spark', 0),
1590 feature_list=data.get('feature_list', []),
1591 competing_with=data.get('competing_with', []),
1592 platforms=data.get('platforms', ['web']),
1593 distribution_channels=data.get('distribution_channels', []),
1594 benchmark_scores=data.get('benchmark_scores', {}),
1595 product_id=data.get('product_id', ''),
1596 )
1598 if result.get('error'):
1599 return jsonify({'success': False, 'error': result['error']}), 400
1600 return jsonify({'success': True, 'listing': result}), 201
1603@marketplace_bp.route('/api/marketplace/apps/<listing_id>', methods=['PUT'])
1604def update_app(listing_id):
1605 """Update an existing listing."""
1606 data = request.get_json(force=True)
1607 owner_id = data.pop('owner_id', '')
1608 if not owner_id:
1609 return jsonify({'success': False, 'error': 'owner_id required'}), 400
1611 mp = get_marketplace()
1612 result = mp.update_app(listing_id, owner_id, data)
1613 if result.get('error'):
1614 return jsonify({'success': False, 'error': result['error']}), 400
1615 return jsonify({'success': True, 'listing': result})
1618@marketplace_bp.route('/api/marketplace/apps/<listing_id>/compare', methods=['GET'])
1619def compare_app(listing_id):
1620 """Compare an app with its competitors or specific apps.
1622 Query params:
1623 with: comma-separated listing IDs to compare against
1624 """
1625 mp = get_marketplace()
1626 compare_with = request.args.get('with', '')
1627 if compare_with:
1628 ids = [listing_id] + [x.strip() for x in compare_with.split(',') if x.strip()]
1629 else:
1630 # Auto-compare with top competitors
1631 competitors = mp.get_competing_apps(listing_id)
1632 ids = [listing_id] + [c['listing_id'] for c in competitors[:4]]
1634 result = mp.compare_apps(ids)
1635 if result.get('error'):
1636 return jsonify({'success': False, 'error': result['error']}), 400
1637 return jsonify({'success': True, 'comparison': result})
1640@marketplace_bp.route('/api/marketplace/apps/<listing_id>/install', methods=['POST'])
1641def install_app(listing_id):
1642 """Install an app for a user.
1644 Body: { user_id: str }
1645 """
1646 data = request.get_json(force=True)
1647 user_id = data.get('user_id', '')
1648 if not user_id:
1649 return jsonify({'success': False, 'error': 'user_id required'}), 400
1651 mp = get_marketplace()
1652 result = mp.install_app(user_id, listing_id)
1653 if result.get('error'):
1654 code = 409 if 'Already installed' in result['error'] else 400
1655 return jsonify({'success': False, 'error': result['error']}), code
1656 return jsonify(result)
1659@marketplace_bp.route('/api/marketplace/apps/<listing_id>/review', methods=['POST'])
1660def review_app(listing_id):
1661 """Rate and review an app.
1663 Body: { user_id: str, rating: float (1-5), review: str }
1664 """
1665 data = request.get_json(force=True)
1666 user_id = data.get('user_id', '')
1667 rating = data.get('rating', 0)
1668 review = data.get('review', '')
1670 if not user_id:
1671 return jsonify({'success': False, 'error': 'user_id required'}), 400
1672 try:
1673 rating = float(rating)
1674 except (TypeError, ValueError):
1675 return jsonify({'success': False, 'error': 'rating must be a number'}), 400
1677 mp = get_marketplace()
1678 result = mp.rate_app(user_id, listing_id, rating, review)
1679 if result.get('error'):
1680 return jsonify({'success': False, 'error': result['error']}), 400
1681 return jsonify(result)
1684@marketplace_bp.route('/api/marketplace/trending', methods=['GET'])
1685def trending_apps():
1686 """Get trending apps (most installed in last 7 days)."""
1687 mp = get_marketplace()
1688 days = request.args.get('days', 7, type=int)
1689 limit = min(50, max(1, request.args.get('limit', 20, type=int)))
1690 trending = mp.get_trending(days=days, limit=limit)
1691 return jsonify({'success': True, 'trending': trending, 'count': len(trending)})
1694@marketplace_bp.route('/api/marketplace/categories', methods=['GET'])
1695def list_categories():
1696 """Get all marketplace categories with listing counts."""
1697 mp = get_marketplace()
1698 categories = mp.get_categories()
1699 return jsonify({'success': True, 'categories': categories})
1702@marketplace_bp.route('/api/marketplace/apps/<listing_id>/promote', methods=['POST'])
1703def promote_app(listing_id):
1704 """Trigger auto-promotion for an app."""
1705 agent = get_promotion_agent()
1706 result = agent.auto_promote(listing_id)
1707 if result.get('error'):
1708 return jsonify({'success': False, 'error': result['error']}), 400
1709 return jsonify({'success': True, 'promotion': result})
1712@marketplace_bp.route('/api/marketplace/apps/<listing_id>/distribute', methods=['POST'])
1713def distribute_app(listing_id):
1714 """Distribute an app to a specific platform or channel.
1716 Body: { platform: str, channel: str }
1717 One of platform or channel is required.
1718 platform: google_play|microsoft_store|apple_store|web|flatpak
1719 channel: discord|telegram|slack|matrix|whatsapp|etc.
1720 """
1721 data = request.get_json(force=True)
1722 mp = get_marketplace()
1724 platform = data.get('platform', '')
1725 channel = data.get('channel', '')
1727 if platform:
1728 distributors = {
1729 'google_play': mp.distribute_to_google_play,
1730 'microsoft_store': mp.distribute_to_microsoft_store,
1731 'apple_store': mp.distribute_to_apple_store,
1732 'web': mp.distribute_to_web,
1733 'flatpak': mp.distribute_to_flatpak,
1734 }
1735 fn = distributors.get(platform)
1736 if not fn:
1737 return jsonify({
1738 'success': False,
1739 'error': f'Unknown platform: {platform}. '
1740 f'Valid: {list(distributors.keys())}',
1741 }), 400
1742 result = fn(listing_id)
1743 elif channel:
1744 result = mp.distribute_to_channel(listing_id, channel)
1745 else:
1746 return jsonify({
1747 'success': False,
1748 'error': 'Either platform or channel is required',
1749 }), 400
1751 if result.get('error'):
1752 return jsonify({'success': False, 'error': result['error']}), 400
1753 return jsonify(result)
1756@marketplace_bp.route('/api/marketplace/revenue/<owner_id>', methods=['GET'])
1757def revenue_report(owner_id):
1758 """Get revenue report for an app creator."""
1759 mp = get_marketplace()
1760 report = mp.get_revenue_report(owner_id)
1761 return jsonify({'success': True, 'report': report})
1764@marketplace_bp.route('/api/marketplace/apps/<listing_id>/feature-matrix', methods=['GET'])
1765def feature_matrix(listing_id):
1766 """Get feature comparison matrix for the category of this app."""
1767 mp = get_marketplace()
1768 listing = mp.get_app(listing_id)
1769 if not listing:
1770 return jsonify({'success': False, 'error': 'Listing not found'}), 404
1771 matrix = mp.feature_comparison_matrix(listing.get('category', 'general'))
1772 return jsonify({'success': True, 'matrix': matrix})
1775logger.info("App Marketplace module loaded")