275 lines
8.5 KiB
Dart
275 lines
8.5 KiB
Dart
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<MultiAgentRole, List<String>> roleSkills;
|
|
final Map<MultiAgentRole, List<String>> codexRoleSkills;
|
|
|
|
factory ArisBundleManifest.fromJson(Map<String, dynamic> json) {
|
|
Map<MultiAgentRole, List<String>> parseRoleSkills(Object? raw) {
|
|
if (raw is! Map) {
|
|
return const <MultiAgentRole, List<String>>{};
|
|
}
|
|
final parsed = <MultiAgentRole, List<String>>{};
|
|
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 <String>[];
|
|
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<String> 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 <String>[]
|
|
: manifest.codexRoleSkills[role] ?? const <String>[];
|
|
return fallback.map(resolve).toList(growable: false);
|
|
}
|
|
}
|
|
|
|
class ArisBundleRepository {
|
|
ArisBundleRepository({
|
|
AssetBundle? assetBundle,
|
|
Future<String> Function()? rootPathResolver,
|
|
Future<List<String>> 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<String> Function()? _rootPathResolver;
|
|
final Future<List<String>> Function()? _assetKeysResolver;
|
|
|
|
ArisBundleManifest? _manifestCache;
|
|
ResolvedArisBundle? _bundleCache;
|
|
|
|
Future<ArisBundleManifest> loadManifest() async {
|
|
final cached = _manifestCache;
|
|
if (cached != null) {
|
|
return cached;
|
|
}
|
|
final raw = await _assetBundle.loadString(manifestAssetPath);
|
|
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
|
final manifest = ArisBundleManifest.fromJson(decoded);
|
|
_manifestCache = manifest;
|
|
return manifest;
|
|
}
|
|
|
|
Future<ResolvedArisBundle> 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<Map<String, String>> loadSkillContents(List<String> absolutePaths) async {
|
|
final loaded = <String, String>{};
|
|
for (final path in absolutePaths) {
|
|
final file = File(path);
|
|
if (!await file.exists()) {
|
|
continue;
|
|
}
|
|
loaded[path] = await file.readAsString();
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
Future<int> countSkillFiles() async {
|
|
final bundle = await ensureReady();
|
|
final skillsDir = Directory(bundle.resolve('skills'));
|
|
if (!await skillsDir.exists()) {
|
|
return 0;
|
|
}
|
|
return skillsDir
|
|
.listSync(recursive: true)
|
|
.whereType<File>()
|
|
.where((file) => file.path.endsWith('SKILL.md'))
|
|
.length;
|
|
}
|
|
|
|
Future<String> _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<void> _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<String> _requiredAssetKeys({
|
|
required ArisBundleManifest manifest,
|
|
required List<String> assetKeys,
|
|
}) {
|
|
final required = <String>{manifestAssetPath};
|
|
final requiredPrefixes = <String>{
|
|
_parentAssetPrefix(manifest.llmChatServerPath),
|
|
_parentAssetPrefix(manifest.llmChatRequirementsPath),
|
|
}..removeWhere((item) => item.isEmpty);
|
|
|
|
for (final skillPath in <String>[
|
|
...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)}';
|
|
}
|
|
}
|