Some checks failed
CI / check (push) Failing after 1m56s
Halkyon's ADR 0044, step 6c (D7, D49 to D52). apps/learn/halkyon_learn_agent is one executable, `halkyon-agent --instance URL sign-in | sync | mcp`: - sign-in keeps the token read from stdin, signs in with it, makes the agent's key pair on its first sign-in, keeps it before setting the public halves on the register (agents.setKeys), and syncs; - it opens its data as the app's _openPersonData does, on halkyon_learn_core and halkyon_content: its own SQLite database, the sync's space router, its keys and those of the spaces shared with it, and the repositories of studysets, cards, notes, paths, files and feedback; - mcp serves ten tools over stdio (dart_mcp): list, read and search the studysets shared with it, suggest a change or a new card or note, report, list a studyset's suggestions and reply. Each syncs first and a suggestion again at once; a refusal is the tool's error; - its files live in $XDG_CONFIG_HOME/halkyon-agent/<host>, 0700 and 0600, refused when others may read them (D50); - tool/install.sh builds it with `dart build cli` (libsodium and SQLite bundled) and copies it to its user on the host, the build before kept (D52). The synced gateways of learning paths and files, and the fakes of the core's gateways, move into halkyon_learn_core (its testing.dart), which the app's tests and the agent's share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
199 lines
7.5 KiB
Dart
199 lines
7.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:halkyon_base_client/halkyon_base_client.dart' as base;
|
|
import 'package:halkyon_content/halkyon_content.dart';
|
|
import 'package:halkyon_learn_agent/src/agent_home.dart';
|
|
import 'package:halkyon_learn_agent/src/agent_library.dart';
|
|
import 'package:halkyon_learn_agent/src/agent_sync.dart';
|
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
|
import 'package:halkyon_learn_core/core/asset/data/asset_repository.dart';
|
|
import 'package:halkyon_learn_core/core/asset/data/sync_asset_gateway.dart';
|
|
import 'package:halkyon_learn_core/core/privacy/data/content_keys.dart';
|
|
import 'package:halkyon_learn_core/core/privacy/data/privacy.dart';
|
|
import 'package:halkyon_learn_core/core/privacy/data/privacy_ledger.dart';
|
|
import 'package:halkyon_learn_core/core/privacy/data/shared_space_keys.dart';
|
|
import 'package:halkyon_learn_core/core/privacy/data/sync_content_key_gateway.dart';
|
|
import 'package:halkyon_learn_core/core/sync/space_router.dart';
|
|
import 'package:halkyon_learn_core/core/sync/sync_space_lookup.dart';
|
|
import 'package:halkyon_learn_core/feature/card/data/card_repository.dart';
|
|
import 'package:halkyon_learn_core/feature/card/data/sync_card_gateway.dart';
|
|
import 'package:halkyon_learn_core/feature/feedback/data/feedback_envelopes.dart';
|
|
import 'package:halkyon_learn_core/feature/feedback/data/feedback_repository.dart';
|
|
import 'package:halkyon_learn_core/feature/feedback/data/serverpod_feedback_gateway.dart';
|
|
import 'package:halkyon_learn_core/feature/feedback/data/sync_feedback_gateway.dart';
|
|
import 'package:halkyon_learn_core/feature/learning_path/data/learning_path_repository.dart';
|
|
import 'package:halkyon_learn_core/feature/learning_path/data/sync_learning_path_gateway.dart';
|
|
import 'package:halkyon_learn_core/feature/note/data/note_repository.dart';
|
|
import 'package:halkyon_learn_core/feature/note/data/sync_note_gateway.dart';
|
|
import 'package:halkyon_learn_core/feature/studyset/data/studyset_repository.dart';
|
|
import 'package:halkyon_learn_core/feature/studyset/data/sync_studyset_gateway.dart';
|
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
|
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
|
|
|
/// The agent signed in on its instance with its data open (Halkyon's ADR
|
|
/// 0044, D7, D43, D49): its library, its sync, and what closes them.
|
|
final class const OpenAgent({
|
|
/// What its tools read and suggest through.
|
|
required final AgentLibrary library,
|
|
|
|
/// Its sync.
|
|
required final AgentSync sync,
|
|
|
|
/// Closes its data and its connection.
|
|
required final Future<void> Function() close,
|
|
});
|
|
|
|
/// Signs the agent in on [instance] with the token kept in [home], makes
|
|
/// and sets its keys on its first sign-in, and opens its data as Learn's
|
|
/// app opens a person's (`_openPersonData`): its local database, its
|
|
/// sync, its keys and those of the spaces shared with it, and the
|
|
/// repositories its library reads and suggests through.
|
|
Future<OpenAgent> openAgent(AgentHome home, Uri instance) async {
|
|
final token =
|
|
await home.token() ??
|
|
(throw const AgentHomeRefused(
|
|
'No token yet: run `halkyon-agent sign-in` with the one your owner '
|
|
'made in their Profile.',
|
|
));
|
|
final sessions = ClientAuthSessionManager(storage: _MemoryAuthStorage());
|
|
final client = protocol.Client('$instance')..authSessionManager = sessions;
|
|
final success = await client.modules.base.agentSignIn.signIn(token: token);
|
|
await sessions.updateSignedInUser(success);
|
|
final me = success.authUserId.uuid;
|
|
final keys = await _keysOf(home, client.modules.base);
|
|
|
|
final db = await client.createSyncSession(
|
|
home.database,
|
|
persistentUserId: success.authUserId,
|
|
);
|
|
final spaceLookup = SyncSpaceLookup(db, me);
|
|
final spaces = SpaceRouter(spaceLookup);
|
|
db.db.newRowSpace = (row) async => switch (await spaces.spaceOf(row)) {
|
|
final space? => protocol.UuidValue.withValidation(space),
|
|
null => null,
|
|
};
|
|
final sync = AgentSync(
|
|
({required onMerge}) => client.offlineSync.syncOnce(
|
|
db,
|
|
onInboundMerge: (space) {
|
|
spaces.forget();
|
|
onMerge();
|
|
},
|
|
),
|
|
);
|
|
final ledger = PrivacyLedger(FilePrivacyLedgerStore(home.privacy));
|
|
await ledger.open();
|
|
final contentKeys = ContentKeys(
|
|
SyncContentKeyGateway(db, sync),
|
|
SodiumContentCipher(),
|
|
() async => keys,
|
|
seen: ledger,
|
|
// An agent always has keys (D6).
|
|
registerHasKeys: () async => true,
|
|
);
|
|
final spaceKeys = SharedSpaceKeys(
|
|
SharedKeys(
|
|
ServerpodSharedKeysGateway(client.modules.base),
|
|
KeyPins(FileKeyPinStore(home.pins)),
|
|
contentKeys.cipher,
|
|
me: me,
|
|
),
|
|
contentKeys.cipher,
|
|
() async => (await spaceLookup.sharedStudysets()).keys.toSet(),
|
|
);
|
|
final privacy = Privacy(db, contentKeys)
|
|
..requestSync = sync.requestSync
|
|
..spaces = spaces
|
|
..spaceKeys = spaceKeys;
|
|
await contentKeys.refresh();
|
|
spaceKeys.keepUpOn(sync.synced);
|
|
|
|
final studysets = StudysetRepository(SyncStudysetGateway(db, sync, privacy));
|
|
final cards = CardRepository(SyncCardGateway(db, sync, me, privacy));
|
|
final notes = NoteRepository(SyncNoteGateway(db, sync, me, privacy));
|
|
final paths = LearningPathRepository(
|
|
SyncLearningPathGateway(db, sync, me, privacy),
|
|
);
|
|
final assets = AssetRepository(
|
|
SyncAssetGateway(db, sync, privacy),
|
|
ServerpodFileServerGateway(client.modules.base),
|
|
DirectoryFileCache(home.files),
|
|
privacy,
|
|
uploadTriggers: sync.synced,
|
|
);
|
|
privacy
|
|
..files = assets
|
|
..maintainOn(sync.merges);
|
|
final envelopes = PrivacyFeedbackEnvelopes(privacy, spaces, studysets);
|
|
final library = AgentLibrary(
|
|
studysets: studysets,
|
|
cards: cards,
|
|
notes: notes,
|
|
feedback: FeedbackRepository(
|
|
SyncFeedbackGateway(db, sync, spaces),
|
|
ServerpodFeedbackGateway(client, envelopes),
|
|
cards,
|
|
notes,
|
|
paths,
|
|
assets,
|
|
envelopes,
|
|
),
|
|
);
|
|
return OpenAgent(
|
|
library: library,
|
|
sync: sync,
|
|
close: () async {
|
|
await sync.dispose();
|
|
await assets.dispose();
|
|
await studysets.dispose();
|
|
await cards.dispose();
|
|
await notes.dispose();
|
|
await paths.dispose();
|
|
await db.close();
|
|
client.close();
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The agent's keys: those kept in [home], else new ones, kept before
|
|
/// their public halves are set on the register, once (D6). Keys kept whose
|
|
/// public halves the register already has stay as they are.
|
|
Future<UnlockedAccountKeys> _keysOf(
|
|
AgentHome home,
|
|
base.Caller agents,
|
|
) async {
|
|
final kept = await home.keys();
|
|
final keys = kept ?? await newAccountKeys();
|
|
if (kept == null) {
|
|
await home.keepKeys(keys);
|
|
}
|
|
if (!await home.keysSet()) {
|
|
try {
|
|
await agents.agents.setKeys(
|
|
encryption: ByteData.sublistView(keys.encryptionPublicKey),
|
|
signing: ByteData.sublistView(keys.signingPublicKey),
|
|
);
|
|
} on base.AgentRefusedException catch (refused) {
|
|
// Set by an earlier run that stopped before it noted so; keys made
|
|
// anew now aren't those, and the agent can't take part.
|
|
if (refused.reason != base.AgentRefusal.keysSet || kept == null) {
|
|
rethrow;
|
|
}
|
|
}
|
|
await home.noteKeysSet();
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/// Keeps the agent's session in memory: it signs in with its token at
|
|
/// every start, for a day's session (D43).
|
|
final class _MemoryAuthStorage() implements ClientAuthSuccessStorage {
|
|
AuthSuccess? _session;
|
|
|
|
@override
|
|
Future<AuthSuccess?> get() async => _session;
|
|
|
|
@override
|
|
Future<void> set(AuthSuccess? data) async => _session = data;
|
|
}
|