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

1""" 

2App Marketplace — Google Play + Microsoft Store + Apple App Store for HART OS. 

3 

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. 

8 

9JSON-backed listing registry at agent_data/marketplace/ (no schema migration). 

10Thread-safe, atomic writes, EventBus integration. 

11 

12Singleton: get_marketplace() 

13Blueprint: marketplace_bp (register on Flask app) 

14Goal seed: SEED_APP_MARKETPLACE_PROMOTER 

15""" 

16 

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 

26 

27logger = logging.getLogger('hevolve.marketplace') 

28 

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') 

35 

36# ─── Categories ───────────────────────────────────────────────────────────── 

37 

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] 

44 

45# ─── Pricing models ──────────────────────────────────────────────────────── 

46 

47PRICING_MODELS = ['free', 'freemium', 'paid', 'subscription'] 

48 

49# ─── Platform targets ────────────────────────────────────────────────────── 

50 

51SUPPORTED_PLATFORMS = ['windows', 'linux', 'mac', 'android', 'ios', 'web'] 

52 

53# ─── Revenue split (imports canonical constants) ─────────────────────────── 

54 

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 

63 

64 

65# ═══════════════════════════════════════════════════════════════════════════ 

66# Atomic JSON persistence 

67# ═══════════════════════════════════════════════════════════════════════════ 

68 

69def _ensure_dir(): 

70 os.makedirs(_MARKETPLACE_DIR, exist_ok=True) 

71 

72 

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) 

80 

81 

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 {} 

92 

93 

94# ═══════════════════════════════════════════════════════════════════════════ 

95# EventBus helpers 

96# ═══════════════════════════════════════════════════════════════════════════ 

97 

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 

105 

106 

107# ═══════════════════════════════════════════════════════════════════════════ 

108# AppMarketplace 

109# ═══════════════════════════════════════════════════════════════════════════ 

110 

111class AppMarketplace: 

112 """Full-featured marketplace for AI apps/agents built on HART OS. 

113 

114 JSON-backed registry at agent_data/marketplace/. Thread-safe mutations. 

115 Every sale follows the 90/9/1 revenue split. 

116 """ 

117 

118 def __init__(self): 

119 self._lock = threading.Lock() 

120 _ensure_dir() 

121 

122 # ─── Private persistence helpers ─────────────────────────────────── 

123 

124 def _listings(self) -> Dict[str, Dict]: 

125 return _load_json(_LISTINGS_PATH, {}) 

126 

127 def _save_listings(self, data: Dict[str, Dict]) -> None: 

128 _atomic_write(_LISTINGS_PATH, data) 

129 

130 def _reviews(self) -> Dict[str, List[Dict]]: 

131 return _load_json(_REVIEWS_PATH, {}) 

132 

133 def _save_reviews(self, data: Dict[str, List[Dict]]) -> None: 

134 _atomic_write(_REVIEWS_PATH, data) 

135 

136 def _installs(self) -> Dict[str, List[Dict]]: 

137 return _load_json(_INSTALLS_PATH, {}) 

138 

139 def _save_installs(self, data: Dict[str, List[Dict]]) -> None: 

140 _atomic_write(_INSTALLS_PATH, data) 

141 

142 def _revenue(self) -> Dict[str, List[Dict]]: 

143 return _load_json(_REVENUE_PATH, {}) 

144 

145 def _save_revenue(self, data: Dict[str, List[Dict]]) -> None: 

146 _atomic_write(_REVENUE_PATH, data) 

147 

148 # ─── Listing CRUD ────────────────────────────────────────────────── 

149 

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' 

168 

169 listing_id = str(uuid.uuid4()) 

170 now = datetime.utcnow().isoformat() 

171 

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 } 

199 

200 with self._lock: 

201 listings = self._listings() 

202 listings[listing_id] = listing 

203 self._save_listings(listings) 

204 

205 _emit('marketplace.app.published', { 

206 'listing_id': listing_id, 

207 'name': name, 

208 'owner_id': owner_id, 

209 'category': category, 

210 }) 

211 

212 logger.info(f"Marketplace: published '{name}' ({listing_id}) by {owner_id}") 

213 return listing 

214 

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'} 

225 

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 

241 

242 listing['updated_at'] = datetime.utcnow().isoformat() 

243 listings[listing_id] = listing 

244 self._save_listings(listings) 

245 

246 return listing 

247 

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 

254 

255 result = dict(listing) 

256 reviews = self._reviews() 

257 result['reviews'] = reviews.get(listing_id, []) 

258 

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] 

274 

275 return result 

276 

277 # ─── Search & Browse ─────────────────────────────────────────────── 

278 

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. 

283 

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) 

304 

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) 

316 

317 # Paginate 

318 total = len(results) 

319 start = (page - 1) * per_page 

320 end = start + per_page 

321 page_results = results[start:end] 

322 

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 } 

330 

331 def search_apps(self, query: str, filters: Dict = None) -> Dict: 

332 """Full-text search with optional filters. 

333 

334 Filters: category, pricing_model, min_rating, platform 

335 """ 

336 filters = filters or {} 

337 listings = self._listings() 

338 results = [] 

339 q_lower = query.lower() 

340 

341 for lid, listing in listings.items(): 

342 if listing.get('status') != 'active': 

343 continue 

344 

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 

355 

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 

366 

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 

375 

376 results.append({**listing, '_search_score': score}) 

377 

378 results.sort(key=lambda x: x.get('_search_score', 0), reverse=True) 

379 

380 # Strip internal score 

381 for r in results: 

382 r.pop('_search_score', None) 

383 

384 return { 

385 'query': query, 

386 'filters': filters, 

387 'results': results[:50], 

388 'total': len(results), 

389 } 

390 

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) 

399 

400 if len(apps) < 2: 

401 return {'error': 'Need at least 2 valid listings to compare'} 

402 

403 # Build feature union 

404 all_features = set() 

405 for app in apps: 

406 all_features.update(app.get('feature_list', [])) 

407 

408 comparison = { 

409 'apps': [], 

410 'feature_matrix': {}, 

411 'all_features': sorted(all_features), 

412 } 

413 

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 } 

429 

430 return comparison 

431 

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 [] 

438 

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 }) 

454 

455 competitors.sort(key=lambda x: x.get('install_count', 0), reverse=True) 

456 return competitors 

457 

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() 

463 

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 

473 

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 

485 

486 return trending 

487 

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 

497 

498 result = [] 

499 for cat in APP_CATEGORIES: 

500 result.append({ 

501 'category': cat, 

502 'count': counts.get(cat, 0), 

503 }) 

504 return result 

505 

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() 

511 

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', [])) 

519 

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 } 

530 

531 return { 

532 'category': category, 

533 'features': sorted_features, 

534 'apps': matrix, 

535 'app_count': len(apps_in_cat), 

536 } 

537 

538 # ─── Install & Rate ──────────────────────────────────────────────── 

539 

540 def install_app(self, user_id: str, listing_id: str) -> Dict: 

541 """Install an app (recipe/agent) for a user. 

542 

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'} 

547 

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'} 

555 

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} 

562 

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 

572 

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 

583 

584 # Increment install count 

585 listing['install_count'] = listing.get('install_count', 0) + 1 

586 listings[listing_id] = listing 

587 

588 self._save_installs(installs) 

589 self._save_listings(listings) 

590 

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) 

595 

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 }) 

602 

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 } 

611 

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'} 

619 

620 with self._lock: 

621 reviews = self._reviews() 

622 listing_reviews = reviews.get(listing_id, []) 

623 

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} 

635 

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) 

649 

650 _emit('marketplace.app.reviewed', { 

651 'listing_id': listing_id, 

652 'user_id': user_id, 

653 'rating': rating, 

654 }) 

655 

656 return {'success': True, 'review_id': review_record['review_id']} 

657 

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 

664 

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 

672 

673 listings[listing_id] = listing 

674 self._save_listings(listings) 

675 

676 # ─── Revenue ─────────────────────────────────────────────────────── 

677 

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. 

681 

682 90% to creator, 9% to infrastructure, 1% to central. 

683 Uses ResonanceService.award_spark when available. 

684 

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 

694 

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}'} 

718 

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) 

724 

725 return {'success': True, 'creator_share': creator_share} 

726 

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). 

733 

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) 

756 

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. 

762 

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. 

767 

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 

845 

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() 

851 

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) 

870 

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 }) 

882 

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 } 

893 

894 # ─── Recipe wiring ───────────────────────────────────────────────── 

895 

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. 

899 

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 

914 

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}") 

930 

931 # ─── Channel Distribution ────────────────────────────────────────── 

932 

933 def distribute_to_channel(self, listing_id: str, channel: str) -> Dict: 

934 """Deploy app as bot to a distribution channel. 

935 

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'} 

943 

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 ) 

951 

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) 

970 

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}'} 

980 

981 # ─── Cross-Platform Distribution (Manifest Generation) ───────────── 

982 

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'} 

988 

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} 

1019 

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'} 

1025 

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} 

1057 

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'} 

1063 

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} 

1086 

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'} 

1092 

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} 

1114 

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'} 

1120 

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} 

1148 

1149 

1150# ═══════════════════════════════════════════════════════════════════════════ 

1151# AppPromotionAgent — Auto-marketing engine 

1152# ═══════════════════════════════════════════════════════════════════════════ 

1153 

1154class AppPromotionAgent: 

1155 """Auto-marketing engine for marketplace listings. 

1156 

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 """ 

1163 

1164 def __init__(self, marketplace: AppMarketplace): 

1165 self._marketplace = marketplace 

1166 

1167 def auto_promote(self, listing_id: str) -> Dict: 

1168 """Create automatic marketing campaign for a listing. 

1169 

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'} 

1178 

1179 results = { 

1180 'listing_id': listing_id, 

1181 'name': listing['name'], 

1182 'actions': [], 

1183 } 

1184 

1185 # 1. Generate SEO keywords and marketing copy 

1186 keywords = self._generate_keywords(listing) 

1187 results['generated_keywords'] = keywords 

1188 

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}) 

1192 

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 }) 

1214 

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}) 

1218 

1219 # 5. Schedule re-promotion goal 

1220 repromo_result = self._schedule_repromotion(listing_id) 

1221 results['actions'].append({'type': 'repromotion_scheduled', 'result': repromo_result}) 

1222 

1223 logger.info(f"Marketplace promotion: '{listing['name']}' — " 

1224 f"{len(results['actions'])} actions taken") 

1225 return results 

1226 

1227 def auto_onboard_users(self, listing_id: str, user_id: str) -> Dict: 

1228 """Onboard a user who just installed an app. 

1229 

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'} 

1237 

1238 results = {'listing_id': listing_id, 'user_id': user_id, 'actions': []} 

1239 

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" 

1249 

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)}) 

1261 

1262 return results 

1263 

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'} 

1270 

1271 if not competitors: 

1272 return { 

1273 'listing_id': listing_id, 

1274 'message': 'No competitors found — first mover in category', 

1275 } 

1276 

1277 comparison_ids = [listing_id] + [c['listing_id'] for c in competitors[:4]] 

1278 comparison = self._marketplace.compare_apps(comparison_ids) 

1279 

1280 return { 

1281 'listing_id': listing_id, 

1282 'name': listing['name'], 

1283 'competitor_count': len(competitors), 

1284 'comparison': comparison, 

1285 } 

1286 

1287 def run_benchmark_comparison(self, listing_ids: List[str]) -> Dict: 

1288 """Run the same benchmark task on competing apps, publish results. 

1289 

1290 Dispatches a benchmark goal for each app and collects scores. 

1291 """ 

1292 results = {'benchmarks': [], 'timestamp': datetime.utcnow().isoformat()} 

1293 listings = self._marketplace._listings() 

1294 

1295 for lid in listing_ids: 

1296 listing = listings.get(lid) 

1297 if not listing: 

1298 continue 

1299 

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 

1309 

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 }) 

1336 

1337 return results 

1338 

1339 # ─── Private promotion helpers ───────────────────────────────────── 

1340 

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] 

1356 

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}' 

1380 

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' 

1401 

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}' 

1420 

1421 

1422# ═══════════════════════════════════════════════════════════════════════════ 

1423# Helpers 

1424# ═══════════════════════════════════════════════════════════════════════════ 

1425 

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] 

1429 

1430 

1431# ═══════════════════════════════════════════════════════════════════════════ 

1432# Singleton 

1433# ═══════════════════════════════════════════════════════════════════════════ 

1434 

1435_marketplace: Optional[AppMarketplace] = None 

1436_promotion_agent: Optional[AppPromotionAgent] = None 

1437 

1438 

1439def get_marketplace() -> AppMarketplace: 

1440 """Get the global AppMarketplace singleton.""" 

1441 global _marketplace 

1442 if _marketplace is None: 

1443 _marketplace = AppMarketplace() 

1444 return _marketplace 

1445 

1446 

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 

1453 

1454 

1455# ═══════════════════════════════════════════════════════════════════════════ 

1456# Goal Seed — Auto-Promoter 

1457# ═══════════════════════════════════════════════════════════════════════════ 

1458 

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} 

1482 

1483 

1484# ═══════════════════════════════════════════════════════════════════════════ 

1485# Flask Blueprint 

1486# ═══════════════════════════════════════════════════════════════════════════ 

1487 

1488from flask import Blueprint, jsonify, request 

1489 

1490marketplace_bp = Blueprint('marketplace', __name__) 

1491 

1492 

1493@marketplace_bp.route('/api/marketplace/apps', methods=['GET']) 

1494def list_apps(): 

1495 """List/search marketplace apps. 

1496 

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))) 

1510 

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 

1525 

1526 result = mp.search_apps(query, filters) 

1527 return jsonify({'success': True, **result}) 

1528 

1529 result = mp.list_apps(category=category, sort=sort, page=page, per_page=per_page) 

1530 return jsonify({'success': True, **result}) 

1531 

1532 

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}) 

1541 

1542 

1543@marketplace_bp.route('/api/marketplace/apps', methods=['POST']) 

1544def publish_app(): 

1545 """Publish a new app to the marketplace. 

1546 

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() 

1569 

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 

1575 

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 ) 

1597 

1598 if result.get('error'): 

1599 return jsonify({'success': False, 'error': result['error']}), 400 

1600 return jsonify({'success': True, 'listing': result}), 201 

1601 

1602 

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 

1610 

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}) 

1616 

1617 

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. 

1621 

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]] 

1633 

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}) 

1638 

1639 

1640@marketplace_bp.route('/api/marketplace/apps/<listing_id>/install', methods=['POST']) 

1641def install_app(listing_id): 

1642 """Install an app for a user. 

1643 

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 

1650 

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) 

1657 

1658 

1659@marketplace_bp.route('/api/marketplace/apps/<listing_id>/review', methods=['POST']) 

1660def review_app(listing_id): 

1661 """Rate and review an app. 

1662 

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', '') 

1669 

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 

1676 

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) 

1682 

1683 

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)}) 

1692 

1693 

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}) 

1700 

1701 

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}) 

1710 

1711 

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. 

1715 

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() 

1723 

1724 platform = data.get('platform', '') 

1725 channel = data.get('channel', '') 

1726 

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 

1750 

1751 if result.get('error'): 

1752 return jsonify({'success': False, 'error': result['error']}), 400 

1753 return jsonify(result) 

1754 

1755 

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}) 

1762 

1763 

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}) 

1773 

1774 

1775logger.info("App Marketplace module loaded")