Files
buzz/mobile/lib/shared/agents/owned_agent_directory.dart
klopez4212 344f007f36 Add mobile Bestie onboarding and fix remote agent presence (#8957)
An empty Bestie slot cycles still posters from Fuzzies, Gloopies and
Figgies every five seconds, without creating video players. The next
poster is precached; reduced motion and hidden-screen pauses are
preserved. The launcher uses the neutral “Bestie” label until a
selection is available, then “Chat with …”.

For supported communities with no owned agents, tapping opens Create
agent directly. A successful first-agent setup saves that agent as
Bestie without replacing an existing preference; existing-agent and
unsupported-community paths keep the Agents list.

Verified Builderlab registrations can show sleeping remote agents as
Online. Owner-scoped saved runtime availability now prevents a known
absent or deleted runtime from returning Online after Builderlab
sign-out or a fresh load, even when another community retains its
registration.

Validation: mobile static checks; 4,274 Flutter tests passed with hosted
flows enabled; launcher → cancel → create → Home “Chat with Scout”,
including encrypted persisted selection; sign-out, fresh-load, owner
isolation and deletion regressions. Restoring the old carousel produced
three video-player requests; removing the saved-availability guard
reproduced incorrect Online after sign-out.

The earlier iPhone recording below shows the original carousel and entry
flow and stops before creation. The new poster-only behavior and
completed creation are covered by widget tests, not a new device
recording.


https://github.com/user-attachments/assets/94fcff72-967a-44c2-99d5-27c715e196a2

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-03 18:10:28 +00:00

161 lines
5.7 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../crypto/event_signing.dart';
import '../crypto/nip_oa.dart';
import '../mentions/agent_identity_provider.dart';
import '../relay/relay.dart';
/// Public model information accompanying a verified owned agent profile.
class OwnedAgentDirectoryEntry extends AgentDirectoryEntry {
const OwnedAgentDirectoryEntry({
required super.pubkey,
super.displayName,
super.avatarUrl,
this.model,
this.runtime,
});
final String? model;
/// Runtime from the verified owner-signed registration in this community.
final String? runtime;
}
/// The owner's agents in the active community, as desktop's Agents tab lists
/// them: each agent's newest kind:30177 registration must name it and carry
/// the `runtime` tag of its agent type. Registrations an earlier Buzz wrote
/// have no `runtime` until the agent is imported, so they are not listed.
/// Signed profiles prove ownership.
final ownedAgentDirectoryProvider =
FutureProvider<List<OwnedAgentDirectoryEntry>>((ref) async {
ref.watch(relayConfigProvider);
final owner = ref.watch(myPubkeyProvider);
final session = ref.watch(relaySessionProvider);
if (owner == null) return const [];
// Stay loading until connected: an empty list would read as "no
// listed agents" and hide the owner's agents from mentions. The
// provider rebuilds when the session status changes.
if (session.status != SessionStatus.connected) {
return Completer<List<OwnedAgentDirectoryEntry>>().future;
}
final relay = ref.read(relaySessionProvider.notifier);
return loadOwnedAgentDirectory(
owner,
(filter) => relay.queryRelay([filter]),
);
});
/// Uses the active community's authenticated query boundary and paginates it.
Future<List<OwnedAgentDirectoryEntry>> loadOwnedAgentDirectory(
String owner,
Future<List<NostrEvent>> Function(NostrFilter) query,
) async {
final registrations = <String, NostrEvent>{};
final seen = <String>{};
for (var page = 1; ; page++) {
final events = await query(
NostrFilter(
kinds: const [30177],
authors: [owner],
limit: 100,
extensions: {'page': page},
),
);
final fresh = events.where((event) => seen.add(event.id)).toList();
for (final event in fresh) {
if (event.kind != 30177 || event.pubkey != owner) continue;
final key = _tagValue(event, 'd');
if (key == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(key)) continue;
final previous = registrations[key];
if (previous == null ||
event.createdAt > previous.createdAt ||
(event.createdAt == previous.createdAt &&
event.id.compareTo(previous.id) < 0)) {
registrations[key] = event;
}
}
if (events.length < 100 || fresh.isEmpty) break;
}
final keys = {
for (final MapEntry(:key, :value) in registrations.entries)
if (isListedAgentRegistration(value)) key,
};
final results = <OwnedAgentDirectoryEntry>[];
final ordered = keys.toList();
for (var offset = 0; offset < ordered.length; offset += 50) {
final batch = ordered.skip(offset).take(50).toList();
final events = await query(NostrFilters.profilesBatch(batch));
final latest = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0 || !batch.contains(event.pubkey)) continue;
final previous = latest[event.pubkey];
if (previous == null ||
event.createdAt > previous.createdAt ||
(event.createdAt == previous.createdAt &&
event.id.compareTo(previous.id) < 0)) {
latest[event.pubkey] = event;
}
}
for (final event in latest.values) {
if (verifiedProfileOaOwnerPubkey(event) == owner) {
final profile = AgentDirectoryEntry.fromEvent(event);
String? model;
String? runtime;
try {
final registration = registrations[event.pubkey]!;
// Native libsecp256k1 verification; the pure-Dart codec took
// several milliseconds per registration on the UI isolate.
verifiedNostrEventFromJson(jsonEncode(registration.toJson()));
runtime = _tagValue(registration, 'runtime');
final data = jsonDecode(registration.content);
if (data is Map && data['model'] is String) {
final value = (data['model'] as String).trim();
if (value.isNotEmpty) model = value;
}
} catch (_) {
/* Older registrations may have no metadata. */
}
results.add(
OwnedAgentDirectoryEntry(
pubkey: profile.pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
model: model,
runtime: runtime,
),
);
}
}
}
return results;
}
/// An agent type key, `pluginId/typeId`, as desktop's `RUNTIME` pattern
/// (buzz-app `src/features/agents/service.ts`) accepts it.
final _runtimePattern = RegExp(r'^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$');
/// Whether desktop lists this kind:30177 registration as one of the owner's
/// agents: it names the agent and carries a valid `runtime` tag.
bool isListedAgentRegistration(NostrEvent event) {
if (!_runtimePattern.hasMatch(_tagValue(event, 'runtime') ?? '')) {
return false;
}
try {
final content = jsonDecode(event.content);
return content is Map &&
content['name'] is String &&
(content['name'] as String).trim().isNotEmpty;
} on FormatException {
return false;
}
}
String? _tagValue(NostrEvent event, String name) {
for (final tag in event.tags) {
if (tag.length >= 2 && tag.first == name) return tag[1];
}
return null;
}