import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'runtime_models.dart'; class ArisBundleManifest { const ArisBundleManifest({ required this.schemaVersion, required this.name, required this.bundleVersion, required this.upstreamRepository, required this.upstreamCommit, required this.llmChatServerPath, required this.llmChatRequirementsPath, required this.roleSkills, required this.codexRoleSkills, }); final int schemaVersion; final String name; final String bundleVersion; final String upstreamRepository; final String upstreamCommit; final String llmChatServerPath; final String llmChatRequirementsPath; final Map> roleSkills; final Map> codexRoleSkills; factory ArisBundleManifest.fromJson(Map json) { Map> parseRoleSkills(Object? raw) { if (raw is! Map) { return const >{}; } final parsed = >{}; for (final entry in raw.entries) { final role = MultiAgentRole.values.firstWhere( (item) => item.name == entry.key.toString(), orElse: () => MultiAgentRole.engineer, ); final value = entry.value; final items = value is List ? value .map((item) => item.toString().trim()) .where((item) => item.isNotEmpty) .toList(growable: false) : const []; parsed[role] = items; } return parsed; } return ArisBundleManifest( schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1, name: json['name'] as String? ?? 'ARIS', bundleVersion: json['bundleVersion'] as String? ?? '', upstreamRepository: json['upstreamRepository'] as String? ?? '', upstreamCommit: json['upstreamCommit'] as String? ?? '', llmChatServerPath: json['llmChatServerPath'] as String? ?? '', llmChatRequirementsPath: json['llmChatRequirementsPath'] as String? ?? '', roleSkills: parseRoleSkills(json['roleSkills']), codexRoleSkills: parseRoleSkills(json['codexRoleSkills']), ); } } class ResolvedArisBundle { const ResolvedArisBundle({ required this.rootPath, required this.manifest, }); final String rootPath; final ArisBundleManifest manifest; String resolve(String relativePath) => '$rootPath/$relativePath'; String get llmChatServerPath => resolve(manifest.llmChatServerPath); String get llmChatRequirementsPath => resolve(manifest.llmChatRequirementsPath); List skillPathsForRole( MultiAgentRole role, { bool preferCodex = false, }) { final preferred = preferCodex ? manifest.codexRoleSkills[role] : manifest.roleSkills[role]; if (preferred != null && preferred.isNotEmpty) { return preferred.map(resolve).toList(growable: false); } final fallback = preferCodex ? manifest.roleSkills[role] ?? const [] : manifest.codexRoleSkills[role] ?? const []; return fallback.map(resolve).toList(growable: false); } } class ArisBundleRepository { ArisBundleRepository({ AssetBundle? assetBundle, Future Function()? rootPathResolver, Future> Function()? assetKeysResolver, }) : _assetBundle = assetBundle ?? rootBundle, _rootPathResolver = rootPathResolver, _assetKeysResolver = assetKeysResolver; static const String assetPrefix = 'assets/aris/'; static const String manifestAssetPath = '${assetPrefix}manifest.json'; final AssetBundle _assetBundle; final Future Function()? _rootPathResolver; final Future> Function()? _assetKeysResolver; ArisBundleManifest? _manifestCache; ResolvedArisBundle? _bundleCache; Future loadManifest() async { final cached = _manifestCache; if (cached != null) { return cached; } final raw = await _assetBundle.loadString(manifestAssetPath); final decoded = jsonDecode(raw) as Map; final manifest = ArisBundleManifest.fromJson(decoded); _manifestCache = manifest; return manifest; } Future ensureReady() async { final cached = _bundleCache; if (cached != null) { return cached; } final manifest = await loadManifest(); final rootPath = await _resolveRootPath(); final markerFile = File('$rootPath/.bundle-version'); final directory = Directory(rootPath); final needsExtract = !await directory.exists() || !await markerFile.exists() || (await markerFile.readAsString()).trim() != manifest.bundleVersion; if (needsExtract) { await _extractBundle(rootPath, manifest); } final bundle = ResolvedArisBundle(rootPath: rootPath, manifest: manifest); _bundleCache = bundle; return bundle; } Future> loadSkillContents(List absolutePaths) async { final loaded = {}; for (final path in absolutePaths) { final file = File(path); if (!await file.exists()) { continue; } loaded[path] = await file.readAsString(); } return loaded; } Future countSkillFiles() async { final bundle = await ensureReady(); final skillsDir = Directory(bundle.resolve('skills')); if (!await skillsDir.exists()) { return 0; } return skillsDir .listSync(recursive: true) .whereType() .where((file) => file.path.endsWith('SKILL.md')) .length; } Future _resolveRootPath() async { final override = await _rootPathResolver?.call(); final trimmed = override?.trim() ?? ''; if (trimmed.isNotEmpty) { return trimmed; } final supportDirectory = await getApplicationSupportDirectory(); return '${supportDirectory.path}/xworkmate/aris-bundle'; } Future _extractBundle( String rootPath, ArisBundleManifest manifest, ) async { final directory = Directory(rootPath); if (await directory.exists()) { await directory.delete(recursive: true); } await directory.create(recursive: true); final resolver = _assetKeysResolver; final assetKeys = resolver != null ? await resolver() : (await AssetManifest.loadFromAssetBundle(_assetBundle)) .listAssets() .where((item) => item.startsWith(assetPrefix)) .toList(growable: false); final requiredAssets = _requiredAssetKeys( manifest: manifest, assetKeys: assetKeys, ); for (final assetKey in requiredAssets) { final relativePath = assetKey.substring(assetPrefix.length); if (relativePath.isEmpty) { continue; } final data = await _assetBundle.load(assetKey); final bytes = data.buffer.asUint8List( data.offsetInBytes, data.lengthInBytes, ); final file = File('$rootPath/$relativePath'); await file.parent.create(recursive: true); await file.writeAsBytes(bytes, flush: true); } await File( '$rootPath/.bundle-version', ).writeAsString(manifest.bundleVersion, flush: true); } List _requiredAssetKeys({ required ArisBundleManifest manifest, required List assetKeys, }) { final required = {manifestAssetPath}; final requiredPrefixes = { _parentAssetPrefix(manifest.llmChatServerPath), _parentAssetPrefix(manifest.llmChatRequirementsPath), }..removeWhere((item) => item.isEmpty); for (final skillPath in [ ...manifest.roleSkills.values.expand((items) => items), ...manifest.codexRoleSkills.values.expand((items) => items), ]) { requiredPrefixes.add(_parentAssetPrefix(skillPath)); } for (final assetKey in assetKeys) { if (required.contains(assetKey)) { continue; } if (requiredPrefixes.any((prefix) => assetKey.startsWith(prefix))) { required.add(assetKey); } } return required.toList(growable: false)..sort(); } String _parentAssetPrefix(String relativePath) { final trimmed = relativePath.trim(); if (trimmed.isEmpty) { return ''; } if (trimmed.endsWith('/')) { return assetPrefix + trimmed; } final slashIndex = trimmed.lastIndexOf('/'); if (slashIndex < 0) { return assetPrefix; } return '$assetPrefix${trimmed.substring(0, slashIndex + 1)}'; } }