feat(assistant): Claude reads studysets through a connector on the instance

A Claude plan can't drive a chat inside another app, but Claude's apps
(every plan, the phone apps too) can add a remote MCP server as a custom
connector. The instance now is one (ADR 0006): Serverpod's web server
serves the MCP endpoint (/mcp, Streamable HTTP, JSON answers, no
sessions) with five read-only tools over the person's own synced rows
(their studysets, a studyset's cards and notes a page at a time, a
search across cards, a note, their progress), and the OAuth the
specification asks for: protected resource and authorization server
metadata, dynamic client registration, authorization code with PKCE
(S256 only), tokens bound to the resource, iss in the response, refresh
tokens that rotate. Caddy forwards these paths alone to the web server,
which production now runs on 8082.

People connect with a code from the app (Settings › Assistant › Connect
Claude): eight Crockford characters, valid for ten minutes and once,
typed on the authorization page, which names where it returns to, loads
nothing from elsewhere and may not be framed. Anonymous people can
connect too. Codes and tokens are kept only as SHA-256 hashes; an access
token lives an hour, a refresh token 30 days. The app lists what is
connected and disconnects it, which ends its tokens at once; deleting
the account deletes it; a daily job forgets what expired and clients
nobody connected within a week. InstanceInfo says where the connector is
(additive); the migration only adds server-only tables, so devices and
older apps are untouched.

The handlers take plain requests, so the integration tests drive the
whole flow (registration, the code, tokens, refresh, the tools, another
person's content, disconnecting, expiry, pruning, account deletion)
without a web server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-10 12:13:00 +02:00
commit 56c5b36084
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
70 changed files with 24646 additions and 22 deletions

View file

@ -154,7 +154,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number. - **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. - **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use.
- **Asking Claude** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AskClaudeBar`) and a button that unfolds into one (`AskClaudeButton`): above Create studyset in the Library, in the right lane of a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A question is handed to Claude, never answered here: `ClaudeHandOff` opens `https://claude.ai/new?q=…` (cut at 12,000 characters) with the question and what it is about (`AssistantTopic`: the person's learning, a studyset by name, or the studied card's text), for the person to send in Claude. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude is to read studysets through a connector on the instance instead. - **Asking Claude** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AskClaudeBar`) and a button that unfolds into one (`AskClaudeButton`): above Create studyset in the Library, in the right lane of a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A question is handed to Claude, never answered here: `ClaudeHandOff` opens `https://claude.ai/new?q=…` (cut at 12,000 characters) with the question and what it is about (`AssistantTopic`: the person's learning, a studyset by name, or the studied card's text), for the person to send in Claude. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on). - **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. - **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint. - **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.

View file

@ -101,6 +101,10 @@ The sync tests need a running server (`serverpod start`); run them in
`deploy/` holds a Docker Compose setup: Caddy (HTTPS), the server and PostgreSQL. Copy `deploy/` holds a Docker Compose setup: Caddy (HTTPS), the server and PostgreSQL. Copy
`deploy/.env.example` to `deploy/.env` and fill it in; it explains every value. The `deploy/.env.example` to `deploy/.env` and fill it in; it explains every value. The
licences require offering the source of what the instance runs to the people who use it. licences require offering the source of what the instance runs to the people who use it.
Assistants such as Claude read people's studysets through the instance's MCP connector at
`/mcp` ([ADR 0006](docs/adr/0006-assistant-connector.md)): `webServer` in
`halkyon_learn_server/config/production.yaml` names its public address, as `apiServer`
does for the app.
learn.halkyon.eu itself deploys from `main` once CI is green: `deploy/redeploy.sh`, run by learn.halkyon.eu itself deploys from `main` once CI is green: `deploy/redeploy.sh`, run by
the CI runner described in `deploy/runner/`, deploys only commits signed by a key in the CI runner described in `deploy/runner/`, deploys only commits signed by a key in

View file

@ -1,12 +1,21 @@
# Caddy forwards to the API server only. Insights (8081) and a web server (8082) don't # Caddy forwards to the API server, and the assistant connector's paths (ADR 0006) to
# run in production; if Insights is ever re-enabled, reach it through an SSH tunnel, # the web server (8082), which serves nothing else. Insights (8081) doesn't run in
# never through this file. # production; if it is ever re-enabled, reach it through an SSH tunnel, never through
# this file.
# #
# {$DOMAIN} is `learn.halkyon.eu` on sahaquiel, where Caddy obtains and renews a # {$DOMAIN} is `learn.halkyon.eu` on sahaquiel, where Caddy obtains and renews a
# Let's Encrypt certificate by itself, and `http://localhost` for the local rehearsal. # Let's Encrypt certificate by itself, and `http://localhost` for the local rehearsal.
{$DOMAIN} { {$DOMAIN} {
encode zstd gzip encode zstd gzip
# The assistant connector: the MCP endpoint, its OAuth pages, and their metadata.
@connector path /mcp /oauth/* /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/* /.well-known/oauth-authorization-server
handle @connector {
reverse_proxy server:8082
}
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is. # Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
reverse_proxy server:8080 handle {
reverse_proxy server:8080
}
} }

View file

@ -0,0 +1,81 @@
# ADR 0006: Claude reads studysets through a connector on the instance
- **Status:** accepted
- **Date:** 2026-10-10
## Context
People want to ask an assistant about what they learn: explain a card, quiz them on a
studyset, say what to repeat. The assistant should be able to read their material,
reading only for now, writing perhaps later. The first one is Claude; others, such as
ChatGPT, should be possible.
Most people who use Claude pay for a plan (Free, Pro, Max), not for the API. A plan
can't drive a chat inside another app: Anthropic allows no third party to offer
sign-in with Claude or to use a plan's limits ("Unless previously approved, Anthropic
does not allow third party developers to offer claude.ai login or rate limits for their
products", Agent SDK documentation, enforced since spring 2026). An in-app chat would
need everyone to buy API access and paste a key.
Claude's apps, on every plan and on phones too, can add a **custom connector**: a remote
MCP server that Claude calls from Anthropic's cloud, signed in with OAuth. The
conversation then happens in Claude, and Claude reads through the connector's tools
whatever it needs. ChatGPT and other clients speak MCP as well.
## Decision
1. **The instance is the connector.** It already holds every person's content in
plaintext (ADR 0002) and is reachable from the internet, which a phone isn't. Its web
server serves the MCP endpoint (`/mcp`, Streamable HTTP, answering with JSON, no
sessions) and the OAuth it needs. Caddy forwards those paths to it; the API server is
untouched.
2. **Read-only tools**, the person's own content only, whoever's token it is:
studysets, their cards and notes, a search across cards, and progress. Each tool is
marked read-only. Writing comes later, as tools of its own behind a scope of its
own.
3. **Connecting by code.** Anonymous accounts can't sign in on a web page, so the
authorization page asks for a code the app shows (Settings › Assistant): eight
characters, valid for ten minutes, used once, kept only as a hash. The page names
where the person returns to (the client's redirect host), so a look-alike client
can't pass as Claude. The code is the consent; a wrong one is refused without
saying more.
4. **OAuth as MCP specifies (2026-07-28), the parts Claude uses:** protected resource
metadata (RFC 9728), authorization server metadata (RFC 8414), dynamic client
registration (RFC 7591; a public client, or one with a secret in the form or as
Basic), authorization code with PKCE (S256 only), the `resource` parameter bound
into every token (RFC 8707), `iss` in the response (RFC 9207), refresh tokens that
rotate. Redirect URIs are https, or http on the device itself, and compared
exactly; a request naming an unknown client or URI gets an error page, never a
redirect. Client ID metadata documents are not supported yet: fetching a client's
URL from the server needs care against requests into the instance's own network,
and clients fall back to registration.
5. **Tokens are kept as hashes** (SHA-256 of 256 random bits): an access token lives
an hour, a refresh token 30 days from its last use and only once. A connection is
the person's grant to one client, one per client, renewed by connecting again;
the app lists them and disconnects one, which ends its tokens at once. Deleting
the account deletes them; a daily job deletes what expired and clients nobody
connected within a week.
6. **The pages and the endpoint:** the authorization page loads nothing from
elsewhere, runs no script, and may not be framed (`frame-ancestors 'none'`); it
speaks English or German as the browser asks. The MCP endpoint takes POST only,
refuses pages from plain-http origins (DNS rebinding), and every request needs a
token; the tools are built per request for the token's person.
7. **In the app, an opt-in hand-off.** A device setting, off unless turned on, shows a
field on Home and a button in the Library, on a studyset and in a study session.
Each opens `claude.ai/new?q=…` with the question and what it is about written in
(the studyset's name, a card's text), for the person to read and send in Claude.
Nothing is sent from the app; what the conversation contains is Claude's, under
Anthropic's terms.
## Consequences
- People use the plan they have; Halkyon Learn pays for nothing and holds no API key.
- The answer appears in Claude, not inside Halkyon Learn. An in-app chat for people with
an API key can follow behind the same opt-in.
- The instance gains an OAuth authorization server, small but security-relevant: codes
and tokens hashed, redirect URIs matched exactly, PKCE required, the pages not
framable, every path tested.
- What Claude reads leaves the instance for Anthropic, at the person's request; the
privacy policy says so.
- Another instance offers the same once it runs this version; the app shows the
connector's address from what the instance says about itself.

View file

@ -0,0 +1,96 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A code for connecting an assistant, as the app shows it (ADR 0006, rule 3).
abstract class AssistantCode
implements _isc.SerializableModel, _isc.ProtocolSerialization {
AssistantCode._({
required this.code,
required this.expiresAt,
});
factory AssistantCode({
required String code,
required DateTime expiresAt,
}) = _AssistantCodeImpl;
factory AssistantCode.fromJson(Map<String, dynamic> jsonSerialization) {
return AssistantCode(
code: jsonSerialization['code'] as String,
expiresAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['expiresAt'],
),
);
}
/// The code, as `K7QX-M2PD`.
String code;
/// When it stops working, in UTC.
DateTime expiresAt;
/// Returns a shallow copy of this [AssistantCode]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
AssistantCode copyWith({
String? code,
DateTime? expiresAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'AssistantCode',
'code': code,
'expiresAt': expiresAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'AssistantCode',
'code': code,
'expiresAt': expiresAt.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _AssistantCodeImpl extends AssistantCode {
_AssistantCodeImpl({
required String code,
required DateTime expiresAt,
}) : super._(
code: code,
expiresAt: expiresAt,
);
/// Returns a shallow copy of this [AssistantCode]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
AssistantCode copyWith({
String? code,
DateTime? expiresAt,
}) {
return AssistantCode(
code: code ?? this.code,
expiresAt: expiresAt ?? this.expiresAt,
);
}
}

View file

@ -0,0 +1,143 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A connected assistant, as the app lists it.
abstract class AssistantConnectionInfo
implements _isc.SerializableModel, _isc.ProtocolSerialization {
AssistantConnectionInfo._({
required this.id,
required this.name,
required this.host,
required this.connectedAt,
this.lastUsedAt,
});
factory AssistantConnectionInfo({
required int id,
required String name,
required String host,
required DateTime connectedAt,
DateTime? lastUsedAt,
}) = _AssistantConnectionInfoImpl;
factory AssistantConnectionInfo.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return AssistantConnectionInfo(
id: jsonSerialization['id'] as int,
name: jsonSerialization['name'] as String,
host: jsonSerialization['host'] as String,
connectedAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['connectedAt'],
),
lastUsedAt: jsonSerialization['lastUsedAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['lastUsedAt'],
),
);
}
/// The connection's id, to disconnect it.
int id;
/// What the assistant calls itself.
String name;
/// The host the person was sent back to on connecting, e.g. `claude.ai`.
String host;
/// When the person connected it.
DateTime connectedAt;
/// When it last read something; null before.
DateTime? lastUsedAt;
/// Returns a shallow copy of this [AssistantConnectionInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
AssistantConnectionInfo copyWith({
int? id,
String? name,
String? host,
DateTime? connectedAt,
DateTime? lastUsedAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'AssistantConnectionInfo',
'id': id,
'name': name,
'host': host,
'connectedAt': connectedAt.toJson(),
if (lastUsedAt != null) 'lastUsedAt': lastUsedAt?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'AssistantConnectionInfo',
'id': id,
'name': name,
'host': host,
'connectedAt': connectedAt.toJson(),
if (lastUsedAt != null) 'lastUsedAt': lastUsedAt?.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _AssistantConnectionInfoImpl extends AssistantConnectionInfo {
_AssistantConnectionInfoImpl({
required int id,
required String name,
required String host,
required DateTime connectedAt,
DateTime? lastUsedAt,
}) : super._(
id: id,
name: name,
host: host,
connectedAt: connectedAt,
lastUsedAt: lastUsedAt,
);
/// Returns a shallow copy of this [AssistantConnectionInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
AssistantConnectionInfo copyWith({
int? id,
String? name,
String? host,
DateTime? connectedAt,
Object? lastUsedAt = _Undefined,
}) {
return AssistantConnectionInfo(
id: id ?? this.id,
name: name ?? this.name,
host: host ?? this.host,
connectedAt: connectedAt ?? this.connectedAt,
lastUsedAt: lastUsedAt is DateTime? ? lastUsedAt : this.lastUsedAt,
);
}
}

View file

@ -14,6 +14,10 @@ import 'dart:async' as _ida;
import 'dart:typed_data' as _idt; import 'dart:typed_data' as _idt;
import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart' import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart'
as _iimptq0q; as _iimptq0q;
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_code.dart'
as _itx3eecd;
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection_info.dart'
as _iplrdti2;
import 'package:halkyon_learn_client/src/protocol/auth/device.dart' import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9; as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart' import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
@ -109,6 +113,43 @@ class EndpointAsset extends _isc.EndpointRef {
); );
} }
/// Connecting assistants to the signed-in person's account (ADR 0006): a
/// code to type on an assistant's authorization page, and the assistants
/// connected, to disconnect. Anonymous people too: the code is how they
/// connect without signing in anywhere.
/// {@category Endpoint}
class EndpointAssistant extends _isc.EndpointRef {
EndpointAssistant(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'assistant';
/// A new code for connecting an assistant, valid for ten minutes and
/// once; the person's earlier codes stop working. Only its hash is kept.
_ida.Future<_itx3eecd.AssistantCode> connectCode() =>
caller.callServerEndpoint<_itx3eecd.AssistantCode>(
'assistant',
'connectCode',
{},
);
/// The assistants connected to the person's account, the newest first.
_ida.Future<List<_iplrdti2.AssistantConnectionInfo>> connections() =>
caller.callServerEndpoint<List<_iplrdti2.AssistantConnectionInfo>>(
'assistant',
'connections',
{},
);
/// Disconnects the person's assistant [id]: its tokens stop working at
/// once. Someone else's connection is one that doesn't exist.
_ida.Future<void> disconnect(int id) => caller.callServerEndpoint<void>(
'assistant',
'disconnect',
{'id': id},
);
}
/// The signed-in person's account. /// The signed-in person's account.
/// {@category Endpoint} /// {@category Endpoint}
class EndpointAccount extends _isc.EndpointRef { class EndpointAccount extends _isc.EndpointRef {
@ -510,8 +551,8 @@ class EndpointInstance extends _isc.EndpointRef {
String get name => 'instance'; String get name => 'instance';
/// This instance's offer: GitHub sign-in only with the operator's OAuth /// This instance's offer: GitHub sign-in only with the operator's OAuth
/// app, where its source is, the oldest app build it works with, and the /// app, where its source is, the oldest app build it works with, the
/// domain its people's handles are on. /// domain its people's handles are on, and where assistants connect.
_ida.Future<_i26u8vl4.InstanceInfo> info() => _ida.Future<_i26u8vl4.InstanceInfo> info() =>
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>( caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
'instance', 'instance',
@ -718,6 +759,7 @@ class Client extends _isc.ServerpodClientShared {
httpClientOverride: httpClientOverride, httpClientOverride: httpClientOverride,
) { ) {
asset = EndpointAsset(this); asset = EndpointAsset(this);
assistant = EndpointAssistant(this);
account = EndpointAccount(this); account = EndpointAccount(this);
anonymousIdp = EndpointAnonymousIdp(this); anonymousIdp = EndpointAnonymousIdp(this);
devices = EndpointDevices(this); devices = EndpointDevices(this);
@ -736,6 +778,8 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointAsset asset; late final EndpointAsset asset;
late final EndpointAssistant assistant;
late final EndpointAccount account; late final EndpointAccount account;
late final EndpointAnonymousIdp anonymousIdp; late final EndpointAnonymousIdp anonymousIdp;
@ -767,6 +811,7 @@ class Client extends _isc.ServerpodClientShared {
@override @override
Map<String, _isc.EndpointRef> get endpointRefLookup => { Map<String, _isc.EndpointRef> get endpointRefLookup => {
'asset': asset, 'asset': asset,
'assistant': assistant,
'account': account, 'account': account,
'anonymousIdp': anonymousIdp, 'anonymousIdp': anonymousIdp,
'devices': devices, 'devices': devices,

View file

@ -21,6 +21,7 @@ abstract class InstanceInfo
required this.sourceCodeUrl, required this.sourceCodeUrl,
this.minimumAppBuild, this.minimumAppBuild,
this.accountDomain, this.accountDomain,
this.assistantConnectorUrl,
}); });
factory InstanceInfo({ factory InstanceInfo({
@ -28,6 +29,7 @@ abstract class InstanceInfo
required String sourceCodeUrl, required String sourceCodeUrl,
int? minimumAppBuild, int? minimumAppBuild,
String? accountDomain, String? accountDomain,
String? assistantConnectorUrl,
}) = _InstanceInfoImpl; }) = _InstanceInfoImpl;
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) { factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
@ -36,6 +38,8 @@ abstract class InstanceInfo
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String, sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?, minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
accountDomain: jsonSerialization['accountDomain'] as String?, accountDomain: jsonSerialization['accountDomain'] as String?,
assistantConnectorUrl:
jsonSerialization['assistantConnectorUrl'] as String?,
); );
} }
@ -57,6 +61,11 @@ abstract class InstanceInfo
/// it. /// it.
String? accountDomain; String? accountDomain;
/// Where assistants such as Claude connect to read people's studysets (ADR
/// 0006): the MCP endpoint, as `https://learn.halkyon.eu/mcp`. Null where
/// the instance offers none, and from servers that predate it.
String? assistantConnectorUrl;
/// Returns a shallow copy of this [InstanceInfo] /// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -65,6 +74,7 @@ abstract class InstanceInfo
String? sourceCodeUrl, String? sourceCodeUrl,
int? minimumAppBuild, int? minimumAppBuild,
String? accountDomain, String? accountDomain,
String? assistantConnectorUrl,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -74,6 +84,8 @@ abstract class InstanceInfo
'sourceCodeUrl': sourceCodeUrl, 'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild, if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain, if (accountDomain != null) 'accountDomain': accountDomain,
if (assistantConnectorUrl != null)
'assistantConnectorUrl': assistantConnectorUrl,
}; };
} }
@ -85,6 +97,8 @@ abstract class InstanceInfo
'sourceCodeUrl': sourceCodeUrl, 'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild, if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain, if (accountDomain != null) 'accountDomain': accountDomain,
if (assistantConnectorUrl != null)
'assistantConnectorUrl': assistantConnectorUrl,
}; };
} }
@ -102,11 +116,13 @@ class _InstanceInfoImpl extends InstanceInfo {
required String sourceCodeUrl, required String sourceCodeUrl,
int? minimumAppBuild, int? minimumAppBuild,
String? accountDomain, String? accountDomain,
String? assistantConnectorUrl,
}) : super._( }) : super._(
gitHubClientId: gitHubClientId, gitHubClientId: gitHubClientId,
sourceCodeUrl: sourceCodeUrl, sourceCodeUrl: sourceCodeUrl,
minimumAppBuild: minimumAppBuild, minimumAppBuild: minimumAppBuild,
accountDomain: accountDomain, accountDomain: accountDomain,
assistantConnectorUrl: assistantConnectorUrl,
); );
/// Returns a shallow copy of this [InstanceInfo] /// Returns a shallow copy of this [InstanceInfo]
@ -118,6 +134,7 @@ class _InstanceInfoImpl extends InstanceInfo {
String? sourceCodeUrl, String? sourceCodeUrl,
Object? minimumAppBuild = _Undefined, Object? minimumAppBuild = _Undefined,
Object? accountDomain = _Undefined, Object? accountDomain = _Undefined,
Object? assistantConnectorUrl = _Undefined,
}) { }) {
return InstanceInfo( return InstanceInfo(
gitHubClientId: gitHubClientId is String? gitHubClientId: gitHubClientId is String?
@ -130,6 +147,9 @@ class _InstanceInfoImpl extends InstanceInfo {
accountDomain: accountDomain is String? accountDomain: accountDomain is String?
? accountDomain ? accountDomain
: this.accountDomain, : this.accountDomain,
assistantConnectorUrl: assistantConnectorUrl is String?
? assistantConnectorUrl
: this.assistantConnectorUrl,
); );
} }
} }

View file

@ -11,6 +11,8 @@
// ignore_for_file: dead_code, unnecessary_type_check // ignore_for_file: dead_code, unnecessary_type_check
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection_info.dart'
as _iplrdti2;
import 'package:halkyon_learn_client/src/protocol/auth/device.dart' import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9; as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart' import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
@ -33,6 +35,8 @@ import 'asset/asset.dart' as _ipb7vkxr;
import 'asset/file_allowance.dart' as _i9uv0f1n; import 'asset/file_allowance.dart' as _i9uv0f1n;
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq; import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
import 'asset/file_usage.dart' as _iurwo9k8; import 'asset/file_usage.dart' as _iurwo9k8;
import 'assistant/assistant_code.dart' as _i8xi9jqo;
import 'assistant/assistant_connection_info.dart' as _icukzjbz;
import 'auth/device.dart' as _i6c5ny7n; import 'auth/device.dart' as _i6c5ny7n;
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5; import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n; import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
@ -80,6 +84,8 @@ export 'asset/asset.dart';
export 'asset/file_allowance.dart'; export 'asset/file_allowance.dart';
export 'asset/file_limit_exceeded_exception.dart'; export 'asset/file_limit_exceeded_exception.dart';
export 'asset/file_usage.dart'; export 'asset/file_usage.dart';
export 'assistant/assistant_code.dart';
export 'assistant/assistant_connection_info.dart';
export 'auth/device.dart'; export 'auth/device.dart';
export 'auth/github_sign_in_refusal.dart'; export 'auth/github_sign_in_refusal.dart';
export 'auth/github_sign_in_refused_exception.dart'; export 'auth/github_sign_in_refused_exception.dart';
@ -2277,6 +2283,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _iurwo9k8.FileUsage) { if (t == _iurwo9k8.FileUsage) {
return _iurwo9k8.FileUsage.fromJson(data) as T; return _iurwo9k8.FileUsage.fromJson(data) as T;
} }
if (t == _i8xi9jqo.AssistantCode) {
return _i8xi9jqo.AssistantCode.fromJson(data) as T;
}
if (t == _icukzjbz.AssistantConnectionInfo) {
return _icukzjbz.AssistantConnectionInfo.fromJson(data) as T;
}
if (t == _i6c5ny7n.Device) { if (t == _i6c5ny7n.Device) {
return _i6c5ny7n.Device.fromJson(data) as T; return _i6c5ny7n.Device.fromJson(data) as T;
} }
@ -2422,6 +2434,16 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_iurwo9k8.FileUsage?>()) { if (t == _isc.getType<_iurwo9k8.FileUsage?>()) {
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T; return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
} }
if (t == _isc.getType<_i8xi9jqo.AssistantCode?>()) {
return (data != null ? _i8xi9jqo.AssistantCode.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_icukzjbz.AssistantConnectionInfo?>()) {
return (data != null
? _icukzjbz.AssistantConnectionInfo.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i6c5ny7n.Device?>()) { if (t == _isc.getType<_i6c5ny7n.Device?>()) {
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T; return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
} }
@ -2616,6 +2638,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
.toList() .toList()
as T; as T;
} }
if (t == List<_iplrdti2.AssistantConnectionInfo>) {
return (data as List)
.map((e) => deserialize<_iplrdti2.AssistantConnectionInfo>(e))
.toList()
as T;
}
if (t == List<_ieqh4yr9.Device>) { if (t == List<_ieqh4yr9.Device>) {
return (data as List) return (data as List)
.map((e) => deserialize<_ieqh4yr9.Device>(e)) .map((e) => deserialize<_ieqh4yr9.Device>(e))
@ -2677,6 +2705,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i9uv0f1n.FileAllowance => 'FileAllowance', _i9uv0f1n.FileAllowance => 'FileAllowance',
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException', _it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
_iurwo9k8.FileUsage => 'FileUsage', _iurwo9k8.FileUsage => 'FileUsage',
_i8xi9jqo.AssistantCode => 'AssistantCode',
_icukzjbz.AssistantConnectionInfo => 'AssistantConnectionInfo',
_i6c5ny7n.Device => 'Device', _i6c5ny7n.Device => 'Device',
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal', _ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException', _i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
@ -2745,6 +2775,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'FileLimitExceededException'; return 'FileLimitExceededException';
case _iurwo9k8.FileUsage(): case _iurwo9k8.FileUsage():
return 'FileUsage'; return 'FileUsage';
case _i8xi9jqo.AssistantCode():
return 'AssistantCode';
case _icukzjbz.AssistantConnectionInfo():
return 'AssistantConnectionInfo';
case _i6c5ny7n.Device(): case _i6c5ny7n.Device():
return 'Device'; return 'Device';
case _ir1gd9p5.GitHubSignInRefusal(): case _ir1gd9p5.GitHubSignInRefusal():
@ -2871,6 +2905,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'FileUsage') { if (dataClassName == 'FileUsage') {
return deserialize<_iurwo9k8.FileUsage>(data['data']); return deserialize<_iurwo9k8.FileUsage>(data['data']);
} }
if (dataClassName == 'AssistantCode') {
return deserialize<_i8xi9jqo.AssistantCode>(data['data']);
}
if (dataClassName == 'AssistantConnectionInfo') {
return deserialize<_icukzjbz.AssistantConnectionInfo>(data['data']);
}
if (dataClassName == 'Device') { if (dataClassName == 'Device') {
return deserialize<_i6c5ny7n.Device>(data['data']); return deserialize<_i6c5ny7n.Device>(data['data']);
} }

View file

@ -1543,5 +1543,26 @@
"assistantOpenFailed": "Claude ließ sich nicht öffnen.", "assistantOpenFailed": "Claude ließ sich nicht öffnen.",
"assistantPromptLearning": "{question}\n\n(Gefragt aus Halkyon Learn, wo ich mit Lernsets auf {instance} lerne. Wenn du den Connector von Halkyon Learn hast, kannst du sie dort lesen.)", "assistantPromptLearning": "{question}\n\n(Gefragt aus Halkyon Learn, wo ich mit Lernsets auf {instance} lerne. Wenn du den Connector von Halkyon Learn hast, kannst du sie dort lesen.)",
"assistantPromptStudyset": "{question}\n\n(Es geht um mein Lernset „{title}“ in Halkyon Learn auf {instance}. Wenn du den Connector von Halkyon Learn hast, kannst du es dort lesen.)", "assistantPromptStudyset": "{question}\n\n(Es geht um mein Lernset „{title}“ in Halkyon Learn auf {instance}. Wenn du den Connector von Halkyon Learn hast, kannst du es dort lesen.)",
"assistantPromptCard": "{question}\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „{title}“ in Halkyon Learn auf {instance}.\nVorderseite: {front}\nRückseite: {back})" "assistantPromptCard": "{question}\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „{title}“ in Halkyon Learn auf {instance}.\nVorderseite: {front}\nRückseite: {back})",
"assistantConnectTitle": "Claude verbinden",
"assistantConnectSubtitle": "Claude deine Lernsets lesen lassen",
"assistantConnectIntro": "Claude kann über den Konnektor von Halkyon Learn deine Lernsets, Karten, Notizen und deinen Lernstand auf {instance} lesen und dazu antworten. Ändern kann es nichts. Was es liest, geht an Anthropic, unter deren Datenschutzerklärung.",
"assistantStepAdd": "Öffne in Claude Einstellungen › Konnektoren und füge einen eigenen Konnektor namens Halkyon Learn mit dieser Adresse hinzu. Einmal hinzugefügt, gilt er auch in den Apps von Claude.",
"assistantStepCode": "Claude öffnet dann eine Seite, die nach einem Code fragt. Hol ihn dir hier: Er gilt einmal, zehn Minuten lang.",
"assistantShowCode": "Code anzeigen",
"assistantNewCode": "Neuer Code",
"assistantCodeValid": "Gültig bis {time}",
"assistantCopy": "Kopieren",
"assistantCopied": "Kopiert",
"assistantConnected": "Verbunden",
"assistantNoneConnected": "Noch nichts verbunden.",
"assistantConnectedOn": "verbunden am {date}",
"assistantLastRead": "zuletzt gelesen am {date}",
"assistantDisconnect": "Trennen",
"assistantDisconnectTitle": "{name} trennen?",
"assistantDisconnectMessage": "Es kann deine Lernsets nicht mehr lesen. Mit einem neuen Code kannst du es wieder verbinden.",
"assistantNotOffered": "Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in Claude, mit dem, worum es geht.",
"assistantUnreachable": "Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
"assistantRetry": "Erneut versuchen"
} }

View file

@ -1804,5 +1804,31 @@
"assistantPromptStudyset": "{question}\n\n(About my studyset “{title}” in Halkyon Learn on {instance}. If you have the Halkyon Learn connector, you can read it there.)", "assistantPromptStudyset": "{question}\n\n(About my studyset “{title}” in Halkyon Learn on {instance}. If you have the Halkyon Learn connector, you can read it there.)",
"@assistantPromptStudyset": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "instance": {"type": "String"}}}, "@assistantPromptStudyset": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "instance": {"type": "String"}}},
"assistantPromptCard": "{question}\n\n(About a flashcard I am studying, in my studyset “{title}” in Halkyon Learn on {instance}.\nFront: {front}\nBack: {back})", "assistantPromptCard": "{question}\n\n(About a flashcard I am studying, in my studyset “{title}” in Halkyon Learn on {instance}.\nFront: {front}\nBack: {back})",
"@assistantPromptCard": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "front": {"type": "String"}, "back": {"type": "String"}, "instance": {"type": "String"}}} "@assistantPromptCard": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "front": {"type": "String"}, "back": {"type": "String"}, "instance": {"type": "String"}}},
"assistantConnectTitle": "Connect Claude",
"assistantConnectSubtitle": "Let Claude read your studysets",
"assistantConnectIntro": "Claude can read your studysets, cards, notes and progress on {instance} through Halkyon Learn's connector, and answer about them. It can't change anything. What it reads goes to Anthropic, under their privacy policy.",
"@assistantConnectIntro": {"placeholders": {"instance": {"type": "String"}}},
"assistantStepAdd": "In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude's apps too.",
"assistantStepCode": "Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.",
"assistantShowCode": "Show a code",
"assistantNewCode": "New code",
"assistantCodeValid": "Valid until {time}",
"@assistantCodeValid": {"placeholders": {"time": {"type": "String"}}},
"assistantCopy": "Copy",
"assistantCopied": "Copied",
"assistantConnected": "Connected",
"assistantNoneConnected": "Nothing connected yet.",
"assistantConnectedOn": "connected {date}",
"@assistantConnectedOn": {"placeholders": {"date": {"type": "String"}}},
"assistantLastRead": "last read {date}",
"@assistantLastRead": {"placeholders": {"date": {"type": "String"}}},
"assistantDisconnect": "Disconnect",
"assistantDisconnectTitle": "Disconnect {name}?",
"@assistantDisconnectTitle": {"placeholders": {"name": {"type": "String"}}},
"assistantDisconnectMessage": "It can't read your studysets any more. You can connect it again with a new code.",
"assistantNotOffered": "This instance doesn't offer a connector yet. Questions still open in Claude with what they are about.",
"assistantUnreachable": "The instance can't be reached. Try again once you are online.",
"assistantRetry": "Try again"
} }

View file

@ -9162,6 +9162,126 @@ abstract class AppLocalizations {
String back, String back,
String instance, String instance,
); );
/// No description provided for @assistantConnectTitle.
///
/// In en, this message translates to:
/// **'Connect Claude'**
String get assistantConnectTitle;
/// No description provided for @assistantConnectSubtitle.
///
/// In en, this message translates to:
/// **'Let Claude read your studysets'**
String get assistantConnectSubtitle;
/// No description provided for @assistantConnectIntro.
///
/// In en, this message translates to:
/// **'Claude can read your studysets, cards, notes and progress on {instance} through Halkyon Learn\'s connector, and answer about them. It can\'t change anything. What it reads goes to Anthropic, under their privacy policy.'**
String assistantConnectIntro(String instance);
/// No description provided for @assistantStepAdd.
///
/// In en, this message translates to:
/// **'In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude\'s apps too.'**
String get assistantStepAdd;
/// No description provided for @assistantStepCode.
///
/// In en, this message translates to:
/// **'Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.'**
String get assistantStepCode;
/// No description provided for @assistantShowCode.
///
/// In en, this message translates to:
/// **'Show a code'**
String get assistantShowCode;
/// No description provided for @assistantNewCode.
///
/// In en, this message translates to:
/// **'New code'**
String get assistantNewCode;
/// No description provided for @assistantCodeValid.
///
/// In en, this message translates to:
/// **'Valid until {time}'**
String assistantCodeValid(String time);
/// No description provided for @assistantCopy.
///
/// In en, this message translates to:
/// **'Copy'**
String get assistantCopy;
/// No description provided for @assistantCopied.
///
/// In en, this message translates to:
/// **'Copied'**
String get assistantCopied;
/// No description provided for @assistantConnected.
///
/// In en, this message translates to:
/// **'Connected'**
String get assistantConnected;
/// No description provided for @assistantNoneConnected.
///
/// In en, this message translates to:
/// **'Nothing connected yet.'**
String get assistantNoneConnected;
/// No description provided for @assistantConnectedOn.
///
/// In en, this message translates to:
/// **'connected {date}'**
String assistantConnectedOn(String date);
/// No description provided for @assistantLastRead.
///
/// In en, this message translates to:
/// **'last read {date}'**
String assistantLastRead(String date);
/// No description provided for @assistantDisconnect.
///
/// In en, this message translates to:
/// **'Disconnect'**
String get assistantDisconnect;
/// No description provided for @assistantDisconnectTitle.
///
/// In en, this message translates to:
/// **'Disconnect {name}?'**
String assistantDisconnectTitle(String name);
/// No description provided for @assistantDisconnectMessage.
///
/// In en, this message translates to:
/// **'It can\'t read your studysets any more. You can connect it again with a new code.'**
String get assistantDisconnectMessage;
/// No description provided for @assistantNotOffered.
///
/// In en, this message translates to:
/// **'This instance doesn\'t offer a connector yet. Questions still open in Claude with what they are about.'**
String get assistantNotOffered;
/// No description provided for @assistantUnreachable.
///
/// In en, this message translates to:
/// **'The instance can\'t be reached. Try again once you are online.'**
String get assistantUnreachable;
/// No description provided for @assistantRetry.
///
/// In en, this message translates to:
/// **'Try again'**
String get assistantRetry;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -5734,4 +5734,79 @@ class AppLocalizationsDe extends AppLocalizations {
) { ) {
return '$question\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „$title“ in Halkyon Learn auf $instance.\nVorderseite: $front\nRückseite: $back)'; return '$question\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „$title“ in Halkyon Learn auf $instance.\nVorderseite: $front\nRückseite: $back)';
} }
@override
String get assistantConnectTitle => 'Claude verbinden';
@override
String get assistantConnectSubtitle => 'Claude deine Lernsets lesen lassen';
@override
String assistantConnectIntro(String instance) {
return 'Claude kann über den Konnektor von Halkyon Learn deine Lernsets, Karten, Notizen und deinen Lernstand auf $instance lesen und dazu antworten. Ändern kann es nichts. Was es liest, geht an Anthropic, unter deren Datenschutzerklärung.';
}
@override
String get assistantStepAdd =>
'Öffne in Claude Einstellungen › Konnektoren und füge einen eigenen Konnektor namens Halkyon Learn mit dieser Adresse hinzu. Einmal hinzugefügt, gilt er auch in den Apps von Claude.';
@override
String get assistantStepCode =>
'Claude öffnet dann eine Seite, die nach einem Code fragt. Hol ihn dir hier: Er gilt einmal, zehn Minuten lang.';
@override
String get assistantShowCode => 'Code anzeigen';
@override
String get assistantNewCode => 'Neuer Code';
@override
String assistantCodeValid(String time) {
return 'Gültig bis $time';
}
@override
String get assistantCopy => 'Kopieren';
@override
String get assistantCopied => 'Kopiert';
@override
String get assistantConnected => 'Verbunden';
@override
String get assistantNoneConnected => 'Noch nichts verbunden.';
@override
String assistantConnectedOn(String date) {
return 'verbunden am $date';
}
@override
String assistantLastRead(String date) {
return 'zuletzt gelesen am $date';
}
@override
String get assistantDisconnect => 'Trennen';
@override
String assistantDisconnectTitle(String name) {
return '$name trennen?';
}
@override
String get assistantDisconnectMessage =>
'Es kann deine Lernsets nicht mehr lesen. Mit einem neuen Code kannst du es wieder verbinden.';
@override
String get assistantNotOffered =>
'Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in Claude, mit dem, worum es geht.';
@override
String get assistantUnreachable =>
'Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.';
@override
String get assistantRetry => 'Erneut versuchen';
} }

View file

@ -5685,6 +5685,81 @@ class AppLocalizationsEn extends AppLocalizations {
) { ) {
return '$question\n\n(About a flashcard I am studying, in my studyset “$title” in Halkyon Learn on $instance.\nFront: $front\nBack: $back)'; return '$question\n\n(About a flashcard I am studying, in my studyset “$title” in Halkyon Learn on $instance.\nFront: $front\nBack: $back)';
} }
@override
String get assistantConnectTitle => 'Connect Claude';
@override
String get assistantConnectSubtitle => 'Let Claude read your studysets';
@override
String assistantConnectIntro(String instance) {
return 'Claude can read your studysets, cards, notes and progress on $instance through Halkyon Learn\'s connector, and answer about them. It can\'t change anything. What it reads goes to Anthropic, under their privacy policy.';
}
@override
String get assistantStepAdd =>
'In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude\'s apps too.';
@override
String get assistantStepCode =>
'Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.';
@override
String get assistantShowCode => 'Show a code';
@override
String get assistantNewCode => 'New code';
@override
String assistantCodeValid(String time) {
return 'Valid until $time';
}
@override
String get assistantCopy => 'Copy';
@override
String get assistantCopied => 'Copied';
@override
String get assistantConnected => 'Connected';
@override
String get assistantNoneConnected => 'Nothing connected yet.';
@override
String assistantConnectedOn(String date) {
return 'connected $date';
}
@override
String assistantLastRead(String date) {
return 'last read $date';
}
@override
String get assistantDisconnect => 'Disconnect';
@override
String assistantDisconnectTitle(String name) {
return 'Disconnect $name?';
}
@override
String get assistantDisconnectMessage =>
'It can\'t read your studysets any more. You can connect it again with a new code.';
@override
String get assistantNotOffered =>
'This instance doesn\'t offer a connector yet. Questions still open in Claude with what they are about.';
@override
String get assistantUnreachable =>
'The instance can\'t be reached. Try again once you are online.';
@override
String get assistantRetry => 'Try again';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart'; import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart'; import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
@ -231,6 +232,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
GoRoute( GoRoute(
path: RoutePaths.settings, path: RoutePaths.settings,
builder: (context, state) => const SettingsScreen(), builder: (context, state) => const SettingsScreen(),
routes: [
GoRoute(
path: 'assistant',
builder: (context, state) => const ConnectClaudeScreen(),
),
],
), ),
], ],
); );

View file

@ -24,7 +24,9 @@ import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/serverpod_assistant_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
@ -146,6 +148,9 @@ class Dependencies({
/// Hands questions to Claude. /// Hands questions to Claude.
required final ClaudeHandOff claude, required final ClaudeHandOff claude,
/// Connects assistants to the account on the instance (ADR 0006).
required final AssistantRepository assistant,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) { }) {
/// Builds the production graph on [instance], around one Serverpod /// Builds the production graph on [instance], around one Serverpod
@ -201,6 +206,7 @@ class Dependencies({
appVersion: appVersion, appVersion: appVersion,
pomodoro: device.pomodoro, pomodoro: device.pomodoro,
claude: ClaudeHandOff(), claude: ClaudeHandOff(),
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
openPerson: (person) async { openPerson: (person) async {
await previousClosed.timeout( await previousClosed.timeout(
const Duration(seconds: 10), const Duration(seconds: 10),

View file

@ -16,6 +16,9 @@ abstract final class RoutePaths() {
/// Settings, over the tabs. /// Settings, over the tabs.
static const settings = '/settings'; static const settings = '/settings';
/// Connecting Claude to the account (Settings › Assistant), over Settings.
static const connectAssistant = '/settings/assistant';
/// The reading deadlines on a line of days, over the tabs. /// The reading deadlines on a line of days, over the tabs.
static const readingTimeline = '/reading-timeline'; static const readingTimeline = '/reading-timeline';

View file

@ -0,0 +1,111 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// Connecting Claude to the person's account: where the instance's
/// connector is, a code to type on Claude's page, and what is connected.
class ConnectAssistantCubit(final AssistantRepository _assistant)
extends Cubit<ConnectAssistantState> {
this : super(const ConnectAssistantState());
/// Asks the instance where its connector is and what is connected.
Future<void> load() async {
emit(state.copyWith(loading: true, failed: false));
try {
final url = await _assistant.connectorUrl();
final connections = url == null
? const <ConnectedAssistant>[]
: await _assistant.connections();
_emit(
state.copyWith(
loading: false,
offered: url != null,
connectorUrl: url,
connections: connections,
),
);
} on Exception catch (error) {
log('Asking about the connector failed', name: 'Assistant', error: error);
_emit(state.copyWith(loading: false, failed: true));
}
}
/// Gets a new code; the one shown before stops working.
Future<void> newCode() async {
try {
final code = await _assistant.connectCode();
_emit(state.copyWith(code: code, failed: false));
} on Exception catch (error) {
log('Getting a code failed', name: 'Assistant', error: error);
_emit(state.copyWith(failed: true));
}
}
/// Disconnects [assistant], then lists what is still connected.
Future<void> disconnect(ConnectedAssistant assistant) async {
try {
await _assistant.disconnect(assistant.id);
_emit(state.copyWith(connections: await _assistant.connections()));
} on Exception catch (error) {
log('Disconnecting failed', name: 'Assistant', error: error);
_emit(state.copyWith(failed: true));
}
}
void _emit(ConnectAssistantState next) {
if (!isClosed) {
emit(next);
}
}
}
/// What the screen shows of connecting Claude.
final class const ConnectAssistantState({
/// Whether the instance is being asked.
final bool loading = true,
/// Whether the instance offers a connector.
final bool offered = false,
/// The connector's address, to add in Claude.
final String? connectorUrl,
/// The assistants connected.
final List<ConnectedAssistant> connections = const [],
/// The last code got; null before.
final ConnectCode? code,
/// Whether the last request to the instance failed.
final bool failed = false,
}) with Equatable {
/// This state with the given fields replaced.
ConnectAssistantState copyWith({
bool? loading,
bool? offered,
String? connectorUrl,
List<ConnectedAssistant>? connections,
ConnectCode? code,
bool? failed,
}) => ConnectAssistantState(
loading: loading ?? this.loading,
offered: offered ?? this.offered,
connectorUrl: connectorUrl ?? this.connectorUrl,
connections: connections ?? this.connections,
code: code ?? this.code,
failed: failed ?? this.failed,
);
@override
List<Object?> get props => [
loading,
offered,
connectorUrl,
connections,
code,
failed,
];
}

View file

@ -0,0 +1,17 @@
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// The instance's side of connecting assistants (ADR 0006).
abstract interface class AssistantGateway() {
/// Where assistants connect on this instance (its MCP endpoint); null
/// where it offers none.
Future<String?> connectorUrl();
/// A new code for connecting an assistant; the last one stops working.
Future<ConnectCode> connectCode();
/// The assistants connected to the person's account, newest first.
Future<List<ConnectedAssistant>> connections();
/// Disconnects the assistant [id]: it can't read anything from then on.
Future<void> disconnect(int id);
}

View file

@ -0,0 +1,18 @@
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// Connecting assistants such as Claude to the person's account on the
/// instance, so they can read their studysets (ADR 0006).
class AssistantRepository(final AssistantGateway _gateway) {
/// Where assistants connect on this instance; null where it offers none.
Future<String?> connectorUrl() => _gateway.connectorUrl();
/// A new code to type on an assistant's authorization page.
Future<ConnectCode> connectCode() => _gateway.connectCode();
/// The assistants connected, newest first.
Future<List<ConnectedAssistant>> connections() => _gateway.connections();
/// Disconnects the assistant [id].
Future<void> disconnect(int id) => _gateway.disconnect(id);
}

Some files were not shown because too many files have changed in this diff Show more