feat(learn): Learn's agent, an MCP server suggesting in shared studysets
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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 22:06:29 +01:00
commit 54e212a51c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 2925 additions and 314 deletions

View file

@ -0,0 +1,151 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:path/path.dart' as p;
/// Why the agent's home wasn't opened or read: a folder or file others may
/// read or change, or an instance it wouldn't reach safely.
final class const AgentHomeRefused(
/// What to tell the person running it.
final String message,
) implements Exception {
@override
String toString() => message;
}
/// Where the agent keeps its token, keys and data for one instance
/// (Halkyon's ADR 0044, D50): `<root>/<host>[_<port>]`, mode 0700, its
/// files 0600, as ssh keeps keys: what others may read or change is
/// refused rather than read. The agent runs as a user of its own (D7),
/// whose boundary protects them.
final class AgentHome._(
/// The instance's folder.
final Directory folder,
) {
/// The folder for [instance] in [root], made if it isn't there. Refused
/// for an instance over plain HTTP other than this machine, and for a
/// folder others may enter.
static Future<AgentHome> open(Directory root, Uri instance) async {
final local = const {'localhost', '127.0.0.1', '::1'}.contains(
instance.host,
);
if (instance.scheme != 'https' && !(instance.scheme == 'http' && local)) {
throw AgentHomeRefused(
'The instance $instance is reached over HTTPS only, or plain HTTP '
'on this machine: the token would travel in the clear.',
);
}
final name = instance.hasPort
? '${instance.host}_${instance.port}'
: instance.host;
final folder = Directory(p.join(root.path, name));
if (!folder.existsSync()) {
await _private(root, mode: '700');
await folder.create(recursive: true);
await _chmod(folder.path, '700');
}
_refuseShared(folder.path, 'The folder');
return AgentHome._(folder);
}
/// The default root: `$XDG_CONFIG_HOME/halkyon-agent`, else
/// `~/.config/halkyon-agent`.
static Directory defaultRoot() {
final env = Platform.environment;
final config = switch (env['XDG_CONFIG_HOME']) {
final String home when home.isNotEmpty => home,
_ => p.join(env['HOME'] ?? '.', '.config'),
};
return Directory(p.join(config, 'halkyon-agent'));
}
File get _tokenFile => File(p.join(folder.path, 'token'));
File get _keysFile => File(p.join(folder.path, 'keys.json'));
File get _keysSetFile => File(p.join(folder.path, 'keys_set'));
/// The local synced database.
String get database => p.join(folder.path, 'sync.sqlite');
/// The public keys pinned for the members of its spaces.
File get pins => File(p.join(folder.path, 'key_pins.json'));
/// What it saw of privacy, which the instance can't take back.
File get privacy => File(p.join(folder.path, 'privacy.json'));
/// People's files, as the app keeps them.
Directory get files => Directory(p.join(folder.path, 'files'));
/// The token its owner made, kept by `sign-in`; null before.
Future<String?> token() async => (await _read(_tokenFile))?.trim();
/// Keeps [token], for its user alone.
Future<void> keepToken(String token) =>
_write(_tokenFile, '${token.trim()}\n');
/// Its key pair, made on its first sign-in; null before.
Future<UnlockedAccountKeys?> keys() async => switch (await _read(_keysFile)) {
final String json => UnlockedAccountKeys.fromJson(
jsonDecode(json) as Map<String, Object?>,
),
null => null,
};
/// Keeps [keys], for its user alone.
Future<void> keepKeys(UnlockedAccountKeys keys) =>
_write(_keysFile, jsonEncode(keys.toJson()));
/// Whether the register holds its keys' public halves, as noted.
Future<bool> keysSet() async => await _read(_keysSetFile) != null;
/// Notes that the register holds its keys' public halves.
Future<void> noteKeysSet() => _write(_keysSetFile, '');
/// [file]'s text; null where it isn't there; refused where others may
/// read or change it.
Future<String?> _read(File file) async {
if (!file.existsSync()) {
return null;
}
_refuseShared(file.path, 'The file');
return await file.readAsString();
}
/// Writes [text] into [file] made readable to its user alone first, so
/// it is never readable to others, not even for a moment.
static Future<void> _write(File file, String text) async {
final temp = File('${file.path}.new');
await temp.writeAsString('');
await _chmod(temp.path, '600');
await temp.writeAsString(text, flush: true);
await temp.rename(file.path);
}
/// [directory] made, for its user alone where it is new.
static Future<void> _private(
Directory directory, {
required String mode,
}) async {
if (!directory.existsSync()) {
await directory.create(recursive: true);
await _chmod(directory.path, mode);
}
}
static Future<void> _chmod(String path, String mode) async {
final result = await Process.run('chmod', [mode, path]);
if (result.exitCode != 0) {
throw AgentHomeRefused("Couldn't make $path its user's alone.");
}
}
/// Refuses [path] where its group or others may read, write or enter it.
static void _refuseShared(String path, String what) {
if (FileStat.statSync(path).mode & 0x3f != 0) {
throw AgentHomeRefused(
"$what $path is open to others; make it its user's alone "
'(chmod go-rwx) and its token new.',
);
}
}
}

View file

@ -0,0 +1,372 @@
import 'package:halkyon_content/rich_text.dart';
import 'package:halkyon_learn_core/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_core/feature/card/model/card.dart';
import 'package:halkyon_learn_core/feature/card/model/card_content.dart';
import 'package:halkyon_learn_core/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_core/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_core/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_core/feature/note/model/note.dart';
import 'package:halkyon_learn_core/feature/note/model/note_content.dart';
import 'package:halkyon_learn_core/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_core/feature/studyset/model/studyset.dart';
/// Why the agent did nothing: a studyset not shared with it, an item it
/// can't find there, or what it was asked for isn't something it does.
/// Said to the model as the tool's error.
final class const AgentRefused(
/// What to tell the model.
final String message,
) implements Exception {
@override
String toString() => message;
}
/// What the agent reads and suggests (Halkyon's ADR 0044, D7, D10, D51),
/// on Learn's repositories as the app has them: only the studysets shared
/// with it, as text; every change a suggestion in the studyset's space,
/// never a write to the studyset itself, as the agent is a Viewer (D10).
/// What it hands back is JSON, for the model to read.
class AgentLibrary({
required final StudysetRepository studysets,
required final CardRepository cards,
required final NoteRepository notes,
required final FeedbackRepository feedback,
}) {
/// The card types it writes: those whose parts are text alone.
static const writableTypes = {CardType.frontBack, CardType.vocabulary};
/// The studysets shared with it, not deleted, each with its parent, its
/// role and how many cards and notes it holds.
Future<List<Map<String, Object?>>> sharedStudysets() async {
final shared = await _shared();
final listed = <Map<String, Object?>>[];
for (final studyset in shared.values) {
listed.add({
'id': studyset.id,
'title': studyset.content.title,
'parent': shared.containsKey(studyset.parentId)
? studyset.parentId
: null,
'role': studyset.viewOnly ? 'viewer' : 'editor',
if (studyset.isLocked) 'locked': true,
'cards': (await cards.cardsOf(studyset.id)).length,
'notes': (await notes.notesOf(studyset.id)).length,
});
}
return listed;
}
/// The studyset [id] whole: its title, description, topics, cards and
/// notes as text. Refused for one not shared with it.
Future<Map<String, Object?>> studyset(String id) async {
final shared = await _shared();
final studyset = _sharedOne(shared, id);
return {
'id': studyset.id,
'title': studyset.content.title,
if (!studyset.content.description.isBlank)
'description': studyset.content.description.plainText,
'role': studyset.viewOnly ? 'viewer' : 'editor',
if (studyset.isLocked) 'locked': true,
'topics': [
for (final topic in shared.values)
if (topic.parentId == id) topic.id,
],
'cards': [for (final card in await cards.cardsOf(id)) _cardJson(card)],
'notes': [for (final note in await notes.notesOf(id)) _noteJson(note)],
};
}
/// The shared cards whose text holds [query], whatever its case, within
/// [studysetId] and its topics where given.
Future<List<Map<String, Object?>>> search(
String query, {
String? studysetId,
}) async {
final shared = await _shared();
final within = studysetId == null
? shared.keys.toList()
: [
_sharedOne(shared, studysetId).id,
..._topicsOf(studysetId, shared),
];
final needle = query.toLowerCase();
return [
for (final id in within)
for (final card in await cards.cardsOf(id))
if (_textOf(card.content).toLowerCase().contains(needle))
{..._cardJson(card), 'studyset': id},
];
}
/// Suggests a change to the card [cardId]: each part given replaces the
/// card's, the rest kept.
Future<void> suggestCardChange(
String cardId, {
required String message,
String? front,
String? back,
String? reading,
List<String>? meanings,
List<String>? hints,
String? explanation,
}) async {
final (studysetId, card) = await _cardOf(cardId);
if (!writableTypes.contains(card.content.type)) {
throw AgentRefused(
'A ${card.content.type.name} card is changed in the app, not here.',
);
}
await feedback.suggestCardInSpace(
_withParts(
card.content,
front: front,
back: back,
reading: reading,
meanings: meanings,
hints: hints,
explanation: explanation,
),
studysetId: studysetId,
cardId: cardId,
body: message,
);
}
/// Suggests a new card for the studyset [studysetId], of [type]
/// (`frontBack` or `vocabulary`).
Future<void> suggestNewCard(
String studysetId, {
required String front,
required String message,
String type = 'frontBack',
String back = '',
String reading = '',
List<String> meanings = const [],
List<String> hints = const [],
String? explanation,
}) async {
_sharedOne(await _shared(), studysetId);
final cardType = CardType.values.where((t) => t.name == type).firstOrNull;
if (cardType == null || !writableTypes.contains(cardType)) {
throw AgentRefused(
'Cards of type "$type" are written in the app; here frontBack or '
'vocabulary.',
);
}
await feedback.suggestCardInSpace(
_withParts(
CardContent(
type: cardType,
front: RichContent.empty(),
back: RichContent.empty(),
),
front: front,
back: back,
reading: reading,
meanings: meanings,
hints: hints,
explanation: explanation,
),
studysetId: studysetId,
body: message,
);
}
/// Suggests a change to the note [noteId]: its title or text, or both.
Future<void> suggestNoteChange(
String noteId, {
required String message,
String? title,
String? body,
}) async {
final (studysetId, note) = await _noteOf(noteId);
await feedback.suggestNoteInSpace(
NoteContent(
title: title ?? note.content.title,
body: body == null ? note.content.body : RichContent.plain(body),
),
studysetId: studysetId,
noteId: noteId,
body: message,
);
}
/// Suggests a new note for the studyset [studysetId].
Future<void> suggestNewNote(
String studysetId, {
required String title,
required String body,
required String message,
}) async {
_sharedOne(await _shared(), studysetId);
await feedback.suggestNoteInSpace(
NoteContent(title: title, body: RichContent.plain(body)),
studysetId: studysetId,
body: message,
);
}
/// Asks for the card or note ([kind]) [itemId] to go, saying why in
/// [message], which it must.
Future<void> report(
String kind,
String itemId, {
required String message,
}) async {
if (message.trim().isEmpty) {
throw const AgentRefused('Say why it should go.');
}
final (targetKind, studysetId) = switch (kind) {
'card' => (FeedbackTargetKind.card, (await _cardOf(itemId)).$1),
'note' => (FeedbackTargetKind.note, (await _noteOf(itemId)).$1),
_ => throw AgentRefused(
'Only a card or a note is reported, not "$kind".',
),
};
await feedback.reportInSpace(
studysetId: studysetId,
kind: targetKind,
item: itemId,
category: ReportCategory.other,
body: message,
);
}
/// The suggestions and reports within the studyset [studysetId], each
/// with its replies and how it was decided.
Future<List<Map<String, Object?>>> suggestions(String studysetId) async {
_sharedOne(await _shared(), studysetId);
return [
for (final thread in await feedback.threadsInSpaceOf(studysetId))
{
'id': thread.threadId,
'kind': thread.kind.name,
'target': {
'kind': thread.target.kind.name,
'item': thread.target.item,
},
'by': '@${thread.writer.handle}',
'status': thread.status.name,
'message': thread.body,
'replies': [
for (final reply in thread.messages)
{'by': '@${reply.by.handle}', 'message': reply.body},
],
},
];
}
/// Replies [message] to the suggestion or report [threadId] within the
/// studyset [studysetId].
Future<void> reply(
String studysetId,
String threadId, {
required String message,
}) async {
_sharedOne(await _shared(), studysetId);
final thread =
(await feedback.threadsInSpaceOf(studysetId))
.where((thread) => thread.threadId == threadId)
.firstOrNull ??
(throw AgentRefused('No suggestion $threadId in $studysetId.'));
await feedback.reply(thread, message);
}
/// The studysets shared with it, not deleted, by id.
Future<Map<String, Studyset>> _shared() async => {
for (final studyset in await studysets.all())
if (studyset.isShared) studyset.id: studyset,
};
/// The shared studyset [id]; refused for any other.
Studyset _sharedOne(Map<String, Studyset> shared, String id) =>
shared[id] ?? (throw AgentRefused('No studyset $id is shared with you.'));
/// Every topic below [id], at any depth.
List<String> _topicsOf(String id, Map<String, Studyset> shared) => [
for (final topic in shared.values)
if (topic.parentId == id) ...[topic.id, ..._topicsOf(topic.id, shared)],
];
/// The shared studyset holding the card [cardId], and the card.
Future<(String, Card)> _cardOf(String cardId) async {
for (final id in (await _shared()).keys) {
for (final card in await cards.cardsOf(id)) {
if (card.id == cardId) {
return (id, card);
}
}
}
throw AgentRefused('No card $cardId in a studyset shared with you.');
}
/// The shared studyset holding the note [noteId], and the note.
Future<(String, Note)> _noteOf(String noteId) async {
for (final id in (await _shared()).keys) {
for (final note in await notes.notesOf(id)) {
if (note.id == noteId) {
return (id, note);
}
}
}
throw AgentRefused('No note $noteId in a studyset shared with you.');
}
static CardContent _withParts(
CardContent content, {
String? front,
String? back,
String? reading,
List<String>? meanings,
List<String>? hints,
String? explanation,
}) {
final changed = content.copyWith(
front: front == null ? null : RichContent.plain(front),
back: back == null ? null : RichContent.plain(back),
reading: reading,
meanings: meanings,
hints: hints == null
? null
: [for (final h in hints) RichContent.plain(h)],
);
return explanation == null
? changed
: changed.withField(
CardField.explanation,
RichContent.plain(explanation),
);
}
static String _textOf(CardContent content) => [
content.front.plainText,
content.back.plainText,
content.reading,
...content.meanings,
for (final hint in content.hints) hint.plainText,
content.explanation?.plainText ?? '',
].join('\n');
static Map<String, Object?> _cardJson(Card card) {
final content = card.content;
return {
'id': card.id,
'type': content.type.name,
'front': content.front.plainText,
if (!content.back.isBlank) 'back': content.back.plainText,
if (content.reading.isNotEmpty) 'reading': content.reading,
if (content.meanings.isNotEmpty) 'meanings': content.meanings,
if (content.hints.isNotEmpty)
'hints': [for (final hint in content.hints) hint.plainText],
if (content.explanation case final explanation? when !explanation.isBlank)
'explanation': explanation.plainText,
};
}
static Map<String, Object?> _noteJson(Note note) => {
'id': note.id,
'title': note.content.title,
'body': note.content.body.plainText,
};
}

View file

@ -0,0 +1,324 @@
import 'dart:async';
import 'dart:convert';
import 'package:dart_mcp/server.dart';
import 'package:halkyon_learn_agent/src/agent_library.dart';
/// What the model is told of the agent as it connects.
const _instructions =
'Halkyon Learn studysets shared with you, an agent: you read their '
'flashcards and notes and suggest changes, new cards and new notes, which '
"the studyset's owner and editors accept or decline. You change nothing "
"yourself. Suggest in the studyset's language and say why in each "
"message; list a studyset's suggestions to see what was decided.";
/// The agent as an MCP client meets it (Halkyon's ADR 0044, D7, D51): its
/// tools read the studysets shared with it and suggest, through
/// [AgentLibrary]; each call syncs first ([sync]), and a suggestion is
/// synced at once, so it reaches the studyset's deciders. A refusal is the
/// tool's error, which the model reads; a sync that fails leaves what is
/// held to read, and says so.
base class AgentServer(
super.channel,
final AgentLibrary _library, {
/// Syncs the agent's spaces.
required final Future<void> Function() sync,
}) extends MCPServer with ToolsSupport {
this
: super.fromStreamChannel(
implementation: Implementation(
name: 'halkyon-learn-agent',
version: '0.1.0',
),
instructions: _instructions,
) {
_register(
'list_studysets',
'The studysets shared with you: id, title, parent, your role, how '
'many cards and notes.',
{},
read: true,
(args) async => {'studysets': await _library.sharedStudysets()},
);
_register(
'read_studyset',
"A studyset's title, description, topics, flashcards and notes, as "
'text.',
{'studyset_id': Schema.string()},
read: true,
required: ['studyset_id'],
(args) => _library.studyset(_string(args, 'studyset_id')),
);
_register(
'search_cards',
'Flashcards whose text holds the query, whatever its case, in every '
'studyset shared with you or in one and its topics.',
{
'query': Schema.string(),
'studyset_id': Schema.string(description: 'Only in this one.'),
},
read: true,
required: ['query'],
(args) async => {
'cards': await _library.search(
_string(args, 'query'),
studysetId: _optional(args, 'studyset_id'),
),
},
);
_register(
'suggest_card_change',
'Suggests a change to a flashcard (frontBack or vocabulary): each part '
'given replaces its own, the rest kept. Plain text; a new line '
'starts a paragraph.',
{
'card_id': Schema.string(),
'message': Schema.string(description: 'Why.'),
..._cardParts,
},
required: ['card_id', 'message'],
(args) async {
await _library.suggestCardChange(
_string(args, 'card_id'),
message: _string(args, 'message'),
front: _optional(args, 'front'),
back: _optional(args, 'back'),
reading: _optional(args, 'reading'),
meanings: _strings(args, 'meanings'),
hints: _strings(args, 'hints'),
explanation: _optional(args, 'explanation'),
);
return {'suggested': true};
},
);
_register(
'suggest_new_card',
'Suggests a new flashcard for a studyset: frontBack (front and back) '
'or vocabulary (front the word, its reading and meanings).',
{
'studyset_id': Schema.string(),
'message': Schema.string(description: 'Why.'),
'type': Schema.string(
description: 'frontBack (the default) or vocabulary.',
),
..._cardParts,
},
required: ['studyset_id', 'message'],
(args) async {
await _library.suggestNewCard(
_string(args, 'studyset_id'),
type: _optional(args, 'type') ?? 'frontBack',
front:
_optional(args, 'front') ??
(throw const AgentRefused('A new card needs its front.')),
back: _optional(args, 'back') ?? '',
reading: _optional(args, 'reading') ?? '',
meanings: _strings(args, 'meanings') ?? const [],
hints: _strings(args, 'hints') ?? const [],
explanation: _optional(args, 'explanation'),
message: _string(args, 'message'),
);
return {'suggested': true};
},
);
_register(
'suggest_note_change',
"Suggests a change to a note's title or text, or both.",
{
'note_id': Schema.string(),
'message': Schema.string(description: 'Why.'),
'title': Schema.string(),
'body': Schema.string(description: 'The whole text, as it should be.'),
},
required: ['note_id', 'message'],
(args) async {
await _library.suggestNoteChange(
_string(args, 'note_id'),
title: _optional(args, 'title'),
body: _optional(args, 'body'),
message: _string(args, 'message'),
);
return {'suggested': true};
},
);
_register(
'suggest_new_note',
'Suggests a new note for a studyset.',
{
'studyset_id': Schema.string(),
'title': Schema.string(),
'body': Schema.string(),
'message': Schema.string(description: 'Why.'),
},
required: ['studyset_id', 'title', 'body', 'message'],
(args) async {
await _library.suggestNewNote(
_string(args, 'studyset_id'),
title: _string(args, 'title'),
body: _string(args, 'body'),
message: _string(args, 'message'),
);
return {'suggested': true};
},
);
_register(
'report',
'Asks for a flashcard or note to be deleted, saying why: a duplicate, '
'wrong beyond a change, off the topic.',
{
'kind': Schema.string(description: 'card or note.'),
'item_id': Schema.string(),
'message': Schema.string(description: 'Why.'),
},
required: ['kind', 'item_id', 'message'],
(args) async {
await _library.report(
_string(args, 'kind'),
_string(args, 'item_id'),
message: _string(args, 'message'),
);
return {'reported': true};
},
);
_register(
'list_suggestions',
"A studyset's suggestions and reports, yours and others', with "
'their replies and status (open, accepted, declined, closed).',
{'studyset_id': Schema.string()},
read: true,
required: ['studyset_id'],
(args) async => {
'suggestions': await _library.suggestions(
_string(args, 'studyset_id'),
),
},
);
_register(
'reply',
'Replies to a suggestion or report within a studyset.',
{
'studyset_id': Schema.string(),
'thread_id': Schema.string(),
'message': Schema.string(),
},
required: ['studyset_id', 'thread_id', 'message'],
(args) async {
await _library.reply(
_string(args, 'studyset_id'),
_string(args, 'thread_id'),
message: _string(args, 'message'),
);
return {'replied': true};
},
);
}
/// A flashcard's parts, each optional.
static final _cardParts = <String, Schema>{
'front': Schema.string(),
'back': Schema.string(),
'reading': Schema.string(description: "A vocabulary card's reading."),
'meanings': Schema.list(
description: "A vocabulary card's meanings.",
items: Schema.string(),
),
'hints': Schema.list(description: 'Up to six.', items: Schema.string()),
'explanation': Schema.string(),
};
/// Each tool's required arguments, by its name.
final _requiredOf = <String, List<String>>{};
void _register(
String name,
String description,
Map<String, Schema> properties,
Future<Map<String, Object?>> Function(Map<String, Object?> args) run, {
bool read = false,
List<String> required = const [],
}) {
_requiredOf[name] = required;
registerTool(
Tool(
name: name,
description: description,
inputSchema: Schema.object(
properties: properties,
required: required,
),
annotations: ToolAnnotations(
readOnlyHint: read,
destructiveHint: false,
openWorldHint: false,
),
),
(request) => _call(request, run, write: !read),
);
}
Future<CallToolResult> _call(
CallToolRequest request,
Future<Map<String, Object?>> Function(Map<String, Object?> args) run, {
required bool write,
}) async {
final args = request.arguments ?? const {};
String? warning;
Future<void> synced() async {
try {
await sync();
} on Object catch (error) {
warning =
'Not synced ($error): what you read may be behind, and '
'what you suggested goes with the next sync.';
}
}
try {
for (final name in _requiredOf[request.name] ?? const <String>[]) {
if (args[name] == null) {
throw AgentRefused('"$name" is required.');
}
}
await synced();
final result = await run(args);
if (write) {
await synced();
}
return CallToolResult(
content: [
TextContent(
text: jsonEncode({...result, 'warning': ?warning}),
),
],
);
} on AgentRefused catch (refused) {
return CallToolResult(
isError: true,
content: [TextContent(text: refused.message)],
);
}
}
static String _string(Map<String, Object?> args, String name) =>
switch (args[name]) {
final String value => value,
_ => throw AgentRefused('"$name" is required, as text.'),
};
static String? _optional(Map<String, Object?> args, String name) =>
switch (args[name]) {
null => null,
final String value => value,
_ => throw AgentRefused('"$name" is text.'),
};
static List<String>? _strings(Map<String, Object?> args, String name) =>
switch (args[name]) {
null => null,
final List<Object?> values when values.every((v) => v is String) => [
for (final value in values) value! as String,
],
_ => throw AgentRefused('"$name" is a list of text.'),
};
}

View file

@ -0,0 +1,75 @@
import 'dart:async';
import 'package:halkyon_core/halkyon_core.dart';
/// A sync of the agent's that failed, and why.
final class const AgentSyncFailed(
/// What the sync threw.
final Object cause,
) implements Exception {
@override
String toString() => '$cause';
}
/// One sync of the agent's database, telling [onMerge] when it merged
/// changes from elsewhere.
typedef AgentSyncOnce = Future<void> Function({
required void Function() onMerge,
});
/// The agent's sync (Halkyon's ADR 0044, D7): the `SyncRequests` its
/// gateways ask, as halkyon_app's `SyncService` is the app's, without
/// Flutter or a timer of its own: a write's request syncs after a pause
/// ([debounce]), a tool syncs at once ([syncNow]), one sync at a time.
class AgentSync(
final AgentSyncOnce _syncOnce, {
final Duration debounce = const Duration(seconds: 2),
}) implements SyncRequests {
final _merges = StreamController<void>.broadcast();
final _synced = StreamController<void>.broadcast();
Timer? _pause;
/// Why the last sync failed; null once one went through.
Object? _failure;
late final _runner = CoalescingRunner(
_once,
onError: (error, _) => _failure = error,
);
Future<void> _once() async {
_failure = null;
await _syncOnce(onMerge: () => _merges.add(null));
_synced.add(null);
}
@override
Stream<void> get merges => _merges.stream;
/// An event each time a sync went through.
Stream<void> get synced => _synced.stream;
@override
void requestSync() {
_pause?.cancel();
_pause = Timer(debounce, () => unawaited(_runner.run()));
}
/// Syncs now, any request waiting included; throws [AgentSyncFailed]
/// where the last sync failed.
Future<void> syncNow() async {
_pause?.cancel();
await _runner.run();
if (_failure case final failure?) {
throw AgentSyncFailed(failure);
}
}
/// Ends it, once the sync under way, if any, is done.
Future<void> dispose() async {
_pause?.cancel();
await _runner.idle;
await _merges.close();
await _synced.close();
}
}

View file

@ -0,0 +1,199 @@
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;
}