feat(learn): conversations with assistants, kept sealed (ADR 0021, step 3)

The first part of step 3, decided as D47 to D52 in ADR 0021: where a
conversation shows (a sheet over the screen), where kept ones are found
(the field), the scope's chip and that it starts with only what is
ticked, how conversations are kept, and what a device that can't seal
does.

Two synced tables: assistant_conversation (its title, assistant and
scope) and assistant_turn (one question and everything up to its answer,
never changed once written, ADR 0001, rule 2), so two devices continuing
a conversation offline lose nothing. SyncConversationGateway seals every
row with the person's key, a turn bound to its conversation, and reads
no row that isn't sealed: a device that can't seal keeps nothing (D52,
ConversationNotKept). Deleting one blanks it and its turns first (ADR
0002, rule 2). A turn keeps every message a follow-up sends back (tool
calls and results, the providers' own blocks), but no image, only that
one was sent.

The instance's privacy page says conversations are kept, encrypted, and
what the instance sees of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 19:48:00 +01:00
commit b965e05fdb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 36798 additions and 4 deletions

View file

@ -23,6 +23,8 @@ import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -495,6 +497,9 @@ Future<PersonDependencies> _openPersonData(
pathChanges: paths.changes,
);
final settings = SettingsRepository(SyncSettingsGateway(db, sync, privacy));
final conversations = ConversationRepository(
SyncConversationGateway(db, sync, privacy),
);
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
// Count me and the copies' publications, which the instance can't read
// any more (ADR 0019, D3).
@ -588,6 +593,7 @@ Future<PersonDependencies> _openPersonData(
studysetProgress: studysetProgress,
tags: tags,
settings: settings,
conversations: conversations,
assets: assets,
deletions: deletions,
noteDeletions: noteDeletions,
@ -636,6 +642,7 @@ Future<PersonDependencies> _openPersonData(
await studysets.dispose();
await tags.dispose();
await settings.dispose();
await conversations.dispose();
await reviews.dispose();
await cards.dispose();
await notes.dispose();

View file

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
@ -65,6 +66,10 @@ class PersonDependencies({
/// The person's settings, which follow them to every device.
required final SettingsRepository settings,
/// The person's conversations with assistants, kept sealed (ADR 0021,
/// D13).
required final ConversationRepository conversations,
/// The files of the person's content: images, sounds and PDFs.
required final AssetRepository assets,

View file

@ -0,0 +1,43 @@
import 'dart:typed_data';
/// A conversation's row as it opened on this device, with when it was
/// started (from its UUID v7 id; null for an id that doesn't say).
typedef ConversationRow = ({String id, ByteData payload, DateTime? startedAt});
/// A turn's row as it opened on this device, with when it was kept (from
/// its UUID v7 id; null for an id that doesn't say).
typedef TurnRow = ({String id, ByteData payload, DateTime? writtenAt});
/// The boundary to the person's conversations with assistants in their
/// local, synced database (ADR 0021, D13, D51). Tests replace it with a
/// fake.
abstract interface class ConversationGateway() {
/// The conversations that open here, unordered: one that isn't sealed
/// with the person's key, or doesn't open here, is left out.
Future<List<ConversationRow>> conversations();
/// The turns of the conversation [conversationId] that open here,
/// unordered, as [conversations] leaves them out.
Future<List<TurnRow>> turnsOf(String conversationId);
/// Whether this device seals what it writes with the person's key, as
/// anything kept here must be (D52).
Future<bool> seals();
/// Stores a new conversation, sealed; returns its id.
Future<String> insertConversation(ByteData payload);
/// Appends a turn, sealed, to the conversation [conversationId]; returns
/// its id.
Future<String> insertTurn(String conversationId, ByteData payload);
/// Blanks the payload of the conversation [conversationId] and of each of
/// its turns (ADR 0002, rule 2).
Future<void> blankConversation(String conversationId);
/// Deletes the conversation [conversationId] and its turns.
Future<void> deleteConversation(String conversationId);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,212 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
// A conversation's and its turns' content as their synced rows store it (see
// core/content/payload), sealed with the person's key (ADR 0021, D13, D51).
// Schema 1 of both: a conversation's title, assistant and scope; a turn's
// messages and what they counted.
/// The schema this app writes a conversation in.
const conversationSchemaVersion = 1;
/// The schema this app writes a turn in.
const turnSchemaVersion = 1;
/// What a kept turn holds in an image's place: images aren't kept (they
/// would make a turn megabytes), so a follow-up goes without them.
const imageNotKept = '[An image was sent here.]';
/// Encodes a conversation's [title], [assistant] and [scope].
ByteData encodeConversationPayload({
required String title,
required AssistantRef assistant,
required AssistantScope scope,
}) => encodePayload(conversationSchemaVersion, {
'title': title,
'assistant': {'id': assistant.id, if (assistant.own) 'own': true},
'scope': {
'reach': scope.reach.name,
'studyset': ?scope.studysetId,
if (scope.includePrivate) 'private': true,
},
});
/// Decodes the conversation [id]'s payload.
///
/// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know.
AssistantConversation decodeConversationPayload(
String id,
ByteData payload, {
required DateTime startedAt,
}) {
final json = decodePayload(payload, newestSchema: conversationSchemaVersion);
if (json case {
'title': final String title,
'assistant': {'id': final String assistant},
'scope': {'reach': final String reach},
}) {
final scope = json['scope']! as Map<String, Object?>;
return AssistantConversation(
id: id,
title: title,
assistant: AssistantRef(
id: assistant,
own: (json['assistant']! as Map<String, Object?>)['own'] == true,
),
scope: AssistantScope(
reach: AssistantReach.values.firstWhere(
(each) => each.name == reach,
orElse: () => throw FormatException('Not a reach: $reach'),
),
studysetId: switch (scope['studyset']) {
final String studyset => studyset,
_ => null,
},
includePrivate: scope['private'] == true,
),
startedAt: startedAt,
);
}
throw const FormatException('Not a conversation payload.');
}
/// Encodes a turn's [messages] and what they counted.
ByteData encodeTurnPayload(List<LlmMessage> messages, LlmUsage usage) =>
encodePayload(turnSchemaVersion, {
'messages': [for (final message in messages) _messageToJson(message)],
'usage': {'input': usage.inputTokens, 'output': usage.outputTokens},
});
/// Decodes the turn [id]'s payload.
///
/// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know.
AssistantTurn decodeTurnPayload(
String id,
ByteData payload, {
required DateTime writtenAt,
}) {
final json = decodePayload(payload, newestSchema: turnSchemaVersion);
if (json case {'messages': final List<Object?> messages}) {
return AssistantTurn(
id: id,
messages: [for (final message in messages) _messageFromJson(message)],
usage: switch (json['usage']) {
{'input': final int input, 'output': final int output} => LlmUsage(
inputTokens: input,
outputTokens: output,
),
_ => LlmUsage.none,
},
writtenAt: writtenAt,
);
}
throw const FormatException('Not a turn payload.');
}
Map<String, Object?> _messageToJson(LlmMessage message) => {
'role': message.role.name,
'parts': [
for (final part in message.parts)
switch (part) {
LlmText(:final text, :final extra) => {
'type': 'text',
'text': text,
if (extra.isNotEmpty) 'extra': extra,
},
LlmImage() => {'type': 'text', 'text': imageNotKept},
LlmToolCall(:final id, :final name, :final input, :final extra) => {
'type': 'call',
'id': id,
'name': name,
'input': input,
if (extra.isNotEmpty) 'extra': extra,
},
LlmToolResult(:final callId, :final content, :final isError) => {
'type': 'result',
'call': callId,
'content': content,
if (isError) 'error': true,
},
LlmProviderBlock(:final protocol, :final block) => {
'type': 'provider',
'protocol': protocol.key,
'block': block,
},
},
],
};
LlmMessage _messageFromJson(Object? json) {
if (json case {'role': final String role, 'parts': final List<Object?> raw}) {
final parts = [for (final part in raw) _partFromJson(part)];
// What each role may hold, as LlmMessage allows it: anything else isn't
// what this app wrote.
final holds = switch (role) {
'user' => (
LlmPart part,
) => part is! LlmToolCall && part is! LlmProviderBlock,
'assistant' => (
LlmPart part,
) => part is! LlmImage && part is! LlmToolResult,
_ => throw FormatException('Not a role: $role'),
};
if (parts.isEmpty || !parts.every(holds)) {
throw FormatException('Not a turn of $role.');
}
return role == 'user'
? LlmMessage.user(parts)
: LlmMessage.assistant(parts);
}
throw const FormatException('Not a message.');
}
LlmPart _partFromJson(Object? json) => switch (json) {
{'type': 'text', 'text': final String text} => LlmText(
text,
extra: _object(json['extra']),
),
{
'type': 'call',
'id': final String id,
'name': final String name,
'input': final Map<String, Object?> input,
} =>
LlmToolCall(
id: id,
name: name,
input: input,
extra: _object(json['extra']),
),
{
'type': 'result',
'call': final String callId,
'content': final String content,
} =>
LlmToolResult(
callId: callId,
content: content,
isError: json['error'] == true,
),
{
'type': 'provider',
'protocol': final String protocol,
'block': final Map<String, Object?> block,
} =>
LlmProviderBlock(
protocol:
LlmProtocol.named(protocol) ??
(throw FormatException('Not a protocol: $protocol')),
block: block,
),
_ => throw const FormatException('Not a part.'),
};
Map<String, Object?> _object(Object? json) => switch (json) {
final Map<String, Object?> map => map,
_ => const {},
};

View file

@ -0,0 +1,134 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// A conversation not kept: this device can't seal it with the person's
/// key, and nothing is kept unsealed (ADR 0021, D52).
final class const ConversationNotKept() implements Exception;
/// The person's conversations with assistants (ADR 0021, D13, D51): sealed
/// with their key, synced to their devices, a turn appended for each
/// question and never changed, kept until they delete one.
class ConversationRepository(final ConversationGateway _gateway) {
this {
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
}
final _changes = StreamController<void>.broadcast();
late final StreamSubscription<void> _remoteChanges;
/// Every conversation, the newest first, now and after every change,
/// local or synced. A read that fails arrives as an error; the next
/// change reads again.
Stream<List<AssistantConversation>> watch() =>
watchReads(_changes.stream, all, what: 'conversations', logName: _log);
/// The turns of the conversation [id] in order, now and after every
/// change, as [watch].
Stream<List<AssistantTurn>> watchTurns(String id) => watchReads(
_changes.stream,
() => turnsOf(id),
what: 'turns',
logName: _log,
);
/// Every conversation on this device now, the newest first.
Future<List<AssistantConversation>> all() async => [
for (final row in await _gateway.conversations()) ?_decodeConversation(row),
]..sort((a, b) => b.id.compareTo(a.id));
/// The turns of the conversation [id] on this device now, in the order
/// they were kept (their UUID v7 ids', ADR 0001, rule 7).
Future<List<AssistantTurn>> turnsOf(String id) async => [
for (final row in await _gateway.turnsOf(id)) ?_decodeTurn(row),
]..sort((a, b) => a.id.compareTo(b.id));
/// Whether this device keeps conversations: only where it seals them.
Future<bool> keeps() => _gateway.seals();
/// Starts a conversation with [assistant] in [scope], titled by its first
/// [question]; returns its id. Throws [ConversationNotKept] where this
/// device can't seal it, having written nothing.
Future<String> start({
required String question,
required AssistantRef assistant,
required AssistantScope scope,
}) async {
await _refuseUnsealed();
final id = await _gateway.insertConversation(
encodeConversationPayload(
title: AssistantConversation.titleOf(question),
assistant: assistant,
scope: scope,
),
);
_changes.add(null);
return id;
}
/// Keeps a turn of the conversation [id]: its [messages], the question
/// first, and what their requests counted. Throws [ConversationNotKept]
/// as [start] does.
Future<void> keep(
String id,
List<LlmMessage> messages,
LlmUsage usage,
) async {
await _refuseUnsealed();
await _gateway.insertTurn(id, encodeTurnPayload(messages, usage));
_changes.add(null);
}
/// Deletes the conversation [id] with its turns, blanked first: the sync
/// engine keeps deleted rows until their space goes (ADR 0002, rule 2).
Future<void> delete(String id) async {
await _gateway.blankConversation(id);
await _gateway.deleteConversation(id);
_changes.add(null);
}
/// Stops following remote changes.
Future<void> dispose() async {
await _remoteChanges.cancel();
await _changes.close();
}
Future<void> _refuseUnsealed() async {
if (!await _gateway.seals()) {
throw const ConversationNotKept();
}
}
static const _log = 'Conversations';
AssistantConversation? _decodeConversation(ConversationRow row) {
try {
return decodeConversationPayload(
row.id,
row.payload,
startedAt: row.startedAt ?? DateTime.utc(1970),
);
} on FormatException catch (error) {
log('Skipped conversation ${row.id}: ${error.message}', name: _log);
return null;
}
}
AssistantTurn? _decodeTurn(TurnRow row) {
try {
return decodeTurnPayload(
row.id,
row.payload,
writtenAt: row.writtenAt ?? DateTime.utc(1970),
);
} on FormatException catch (error) {
log('Skipped turn ${row.id}: ${error.message}', name: _log);
return null;
}
}
}

View file

@ -0,0 +1,199 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [ConversationGateway] on the person's local database, which the sync
/// engine keeps in step with their other devices. Everything is sealed with
/// the person's key ([Privacy], ADR 0021, D13), a turn bound to its
/// conversation, so the instance can't move it to another; a row that isn't
/// sealed is never read, as nothing here writes one (D52).
class SyncConversationGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
final Privacy _privacy,
) implements ConversationGateway {
static const _conversations = 'assistant_conversation';
static const _turns = 'assistant_turn';
@override
Future<List<ConversationRow>> conversations() async {
await _privacy.keys.refresh();
return [
for (final row in await protocol.AssistantConversation.db.find(_db))
if (_opened(
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
table: _conversations,
id: row.id!.uuid,
bound: '',
)
case final payload?)
(
id: row.id!.uuid,
payload: payload,
startedAt: uuidV7Time(row.id!.uuid),
),
];
}
@override
Future<List<TurnRow>> turnsOf(String conversationId) async {
await _privacy.keys.refresh();
return [
for (final row in await protocol.AssistantTurn.db.find(
_db,
where: (t) => t.conversationId.equals(_uuid(conversationId)),
))
if (_opened(
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
table: _turns,
id: row.id!.uuid,
bound: 'conversation:$conversationId',
)
case final payload?)
(
id: row.id!.uuid,
payload: payload,
writtenAt: uuidV7Time(row.id!.uuid),
),
];
}
/// [envelope]'s payload where it is sealed and opens here; null for one
/// that is readable (nothing here writes one: the instance made it up),
/// blank (being deleted), or doesn't open.
ByteData? _opened(
Envelope envelope, {
required String table,
required String id,
required String bound,
}) {
if (envelope.keyId == null) {
return null;
}
return switch (_privacy.open(
envelope,
table: table,
id: id,
bound: bound,
)) {
Readable(:final payload) when payload.lengthInBytes > 0 => payload,
_ => null,
};
}
@override
Future<bool> seals() async {
try {
return await _privacy.personalKey() != null;
} on PrivacyRefused {
return false;
}
}
/// The person's key, which everything here is sealed with; refused where
/// this device has none (the repository asks [seals] first).
Future<String> _key() async =>
await _privacy.personalKey() ??
(throw const PrivacyRefused(PrivacyRefusal.keysMissing));
@override
Future<String> insertConversation(ByteData payload) async {
final id = Privacy.newId();
final sealed = _privacy.seal(
payload,
keyId: await _key(),
table: _conversations,
id: id.uuid,
bound: '',
);
await protocol.AssistantConversation.db.insertRow(
_db,
protocol.AssistantConversation(
id: id,
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: sealed.payload,
),
);
_sync.requestSync();
return id.uuid;
}
@override
Future<String> insertTurn(String conversationId, ByteData payload) async {
final id = Privacy.newId();
final sealed = _privacy.seal(
payload,
keyId: await _key(),
table: _turns,
id: id.uuid,
bound: 'conversation:$conversationId',
);
await protocol.AssistantTurn.db.insertRow(
_db,
protocol.AssistantTurn(
id: id,
conversationId: _uuid(conversationId),
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: sealed.payload,
),
);
_sync.requestSync();
return id.uuid;
}
@override
Future<void> blankConversation(String conversationId) async {
final id = _uuid(conversationId);
// Column-scoped updates, which sync like any other edit (ADR 0002,
// rule 2).
await protocol.AssistantTurn.db.update(
_db,
[
for (final turn in await protocol.AssistantTurn.db.find(
_db,
where: (t) => t.conversationId.equals(id),
))
turn.copyWith(payload: redactedPayload),
],
columns: (t) => [t.payload],
);
if (await protocol.AssistantConversation.db.findById(_db, id)
case final conversation?) {
await protocol.AssistantConversation.db.updateRow(
_db,
conversation.copyWith(payload: redactedPayload),
columns: (t) => [t.payload],
);
}
_sync.requestSync();
}
@override
Future<void> deleteConversation(String conversationId) async {
final id = _uuid(conversationId);
await protocol.AssistantTurn.db.deleteWhere(
_db,
where: (t) => t.conversationId.equals(id),
);
await protocol.AssistantConversation.db.deleteWhere(
_db,
where: (t) => t.id.equals(id),
);
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
static protocol.UuidValue _uuid(String id) =>
protocol.UuidValue.withValidation(id);
}

View file

@ -0,0 +1,101 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// Which assistant a conversation is with (ADR 0021): one the operator
/// granted, by the instance's ID of it, or one with the person's own key,
/// by its ID in their settings.
final class const AssistantRef({
/// Its ID.
required final String id,
/// Whether it is one of the person's own.
final bool own = false,
}) with Equatable {
@override
List<Object?> get props => [id, own];
}
/// How far a question reaches beyond the parts ticked (ADR 0021, D7, D49):
/// nothing, the studyset asked on, its tree, or the whole library.
enum AssistantReach() {
/// Only the parts ticked; the model looks nothing up (D50).
ticked,
/// The studyset asked on, with everything inside it.
studyset,
/// The studyset asked on's tree, from its top studyset down.
tree,
/// Every studyset of the person's.
library,
}
/// Where a conversation's questions are asked and how far they reach.
final class const AssistantScope({
/// How far they reach.
final AssistantReach reach = AssistantReach.ticked,
/// The studyset asked on; null on Home or in the Library.
final String? studysetId,
/// Whether private studysets are within [reach] (D8): never unless the
/// person switches it on.
final bool includePrivate = false,
}) with Equatable {
@override
List<Object?> get props => [reach, studysetId, includePrivate];
}
/// A conversation with an assistant, kept sealed (ADR 0021, D13, D51): its
/// [title], who answers, and its scope; its turns are kept apart.
final class const AssistantConversation({
/// Its ID, a UUID v7.
required final String id,
/// What it is called: the first question, cut short.
required final String title,
/// Who answers.
required final AssistantRef assistant,
/// Where it was asked and how far its questions reach.
required final AssistantScope scope,
/// When it was started.
required final DateTime startedAt,
}) with Equatable {
/// How long a [title] is at most.
static const maxTitleLength = 80;
/// [question] as a title: its first line, cut to [maxTitleLength].
static String titleOf(String question) {
final line = question.trim().split('\n').first.trim();
return line.length <= maxTitleLength
? line
: '${line.substring(0, maxTitleLength - 1)}…';
}
@override
List<Object?> get props => [id, title, assistant, scope, startedAt];
}
/// One turn of a conversation, never changed once kept: the person's
/// question and every message up to the model's answer, the lookups' calls
/// and results among them, as they are sent back with a follow-up.
final class const AssistantTurn({
/// Its ID, a UUID v7, by which turns are in order.
required final String id,
/// Its messages, in order: the question first.
required final List<LlmMessage> messages,
/// What its requests counted, together.
final LlmUsage usage = LlmUsage.none,
/// When it was kept.
required final DateTime writtenAt,
}) with Equatable {
@override
List<Object?> get props => [id, messages, usage, writtenAt];
}