First slice of the file-backed IEventStore planned in
cli/plans/2026-04-24-file-event-store-*. Each event is stored as
events/<aa>/<bb>/<id>.json with atomic tmp+rename writes. Ephemeral
kinds are dropped. Duplicate inserts are no-ops (id-level uniqueness).
Staging leftovers are swept on open.
Only id-based query and delete are wired; the query planner, indexes,
replaceable/addressable slots, tombstones, vanish, expiration sweep,
FTS, and transactions land in later steps.
Tests: 10 new tests under quartz jvmTest, all passing.
identity.json previously stored `privKeyHex` and `nsec` as plaintext fields.
0600 file perms keep other OS users out, but not another app running as the
same user — and that is the threat model the CLI actually cares about.
Introduce a SecretStore indirection:
* identity.json now persists only the public parts plus a typed
`secret: IdentitySecret` envelope (keychain | ncryptsec | plaintext).
* macOS uses `/usr/bin/security` (Keychain ACLs bind the item to the binary
that stored it, so other same-user apps need user consent).
* Linux uses `secret-tool` if a Secret Service is running on the session
D-Bus; the gain there is at-rest encryption while the keyring is locked.
* On any platform without a keychain, auto falls back to NIP-49
(scrypt + XChaCha20) with the passphrase read from --passphrase-file,
then $AMY_PASSPHRASE, then a TTY prompt. Another same-user app can read
the blob but cannot decrypt it without the passphrase.
* `--secret-backend=plaintext` is an explicit opt-in for dev scripts and
the interop test harness.
Legacy identity.json files that still carry top-level privKeyHex/nsec are
read transparently and auto-migrate on the next save.
whoami, `create` / `login` existence checks, and init-re-run now use a
metadata-only load path so they do not trigger a keychain prompt or ask
for a passphrase just to echo the npub.
Tests: cli/tests/ setups wire --secret-backend=plaintext through the amy_a
/ amy_d wrappers so headless CI runs do not stall on a TTY passphrase
prompt.
https://claude.ai/code/session_01SqdMfLdXvb3GskFLcEj739
Three-part design doc for an IEventStore backed by a directory tree
instead of SQLite, targeted at the cli/ module.
- overview: goals, directory layout, feature-parity matrix, public API
- pipelines: insert (T1-T8 with crash-safety), query planner, delete,
transactions, concurrency
- nips: replaceable/addressable slot enforcement via hardlinks +
atomic rename, NIP-09 tombstones (hardlink to the kind-5), NIP-40
expirations, NIP-50 FTS via inverted-index hardlinks, NIP-62 vanish
cascade, tag indexing, seed file, scrub/compact, test strategy,
10-step rollout
Adds a new build-cli matrix to create-release.yml so every tag push also
produces self-contained amy binaries:
- amy-<ver>-macos-x64.tar.gz (macos-13 runner)
- amy-<ver>-macos-arm64.tar.gz (macos-14 runner)
- amy-<ver>-linux-x64.tar.gz (ubuntu-latest)
- amy-<ver>-linux-x64.deb (ubuntu-latest)
- amy-<ver>-linux-x64.rpm (ubuntu-latest)
Each tarball is a flat tree (bin/amy + lib/*.jar + runtime/) with a
jlink'd JDK 21 embedded — no system Java required on the user machine.
The .deb / .rpm install the same tree under /opt/amy/.
Windows is intentionally deferred until cli/ is validated on Windows
(data-dir path handling, file locking on groups/<gid>.mls, identity.json
line endings).
Two follow-ups flagged in comments:
- :commons leaks Compose + Skiko (~40 MB) as transitive deps to :cli.
Tarball lands at ~98 MB instead of the plan's <80 MB target. Size
budget in the workflow is set to 200 MB until :commons is split into
core + ui modules.
- .deb/.rpm install to /opt/amy/bin/amy with no /usr/local/bin symlink.
Users must add to PATH or symlink manually after install.
Wire-up:
- cli/build.gradle.kts gains jlinkRuntime + amyImage (Sync task that
combines installDist output with the jlink runtime + a shell
launcher) + jpackageDeb + jpackageRpm.
- scripts/asset-name.sh gains cli_asset_name() + collect_cli_assets()
alongside the existing desktop collector, preserving the
<product>-<version>-<family>-<arch>.<ext> naming contract.
See cli/plans/2026-04-21-cli-distribution.md for the overall plan.
https://claude.ai/code/session_01Tbh6F7TtEeceb4K3stcUWp
The release workflow builds its own signed release APKs/AABs and does
not consume the debug outputs. Testers use the Benchmark APK, which is
still built and uploaded. Dropping assembleDebug removes a redundant
APK packaging pass from every PR/main run.
Combining them into a single Gradle invocation caused
:amethyst:packagePlayBenchmark to fail inside AGP's
IncrementalSplitterRunnable worker — both variants share packaging
state in one task graph and the benchmark leg trips on it.
The old workflow always ran these as two separate `./gradlew` calls.
Match that pattern. They still run on the same runner with a warm
Gradle cache, so the compilation savings from sharing quartz/commons
with the test+packageDeb step are preserved; we only pay two Gradle
startups, which is trivial.
identity.json, relays.json, state.json, MLS group state/retained epochs,
keypackage bundles, and the Marmot message archive were written with
default umask — typically world-readable on Unix. On a shared machine
another OS user could read the stored private key or decrypted chat
history.
Introduce SecureFileIO: overwrites go via a sibling tempfile with POSIX
0600 attrs set at creation time and ATOMIC_MOVE into place, directories
are created 0700, and on upgrade DataDir.init tightens any pre-existing
loose files. Non-POSIX filesystems (Windows) fall back to setReadable/
Writable/Executable to strip other-user access.
This is defense against other OS users. Another app running as the same
user is still in scope — the FileStores doc continues to note that real
deployments MUST encrypt at rest.
https://claude.ai/code/session_01SqdMfLdXvb3GskFLcEj739
Previously each OS ran Gradle twice (or three times on Ubuntu): once to
compile+test, then again to build desktop packages, plus a third run on
Ubuntu for Android APKs. Merging these into a single matrix job per OS
lets Gradle build the task graph once and reuse compiled classes across
test, desktop packaging, and Android assembly.
- Combine test + build-desktop into one matrix job (test-and-build)
- Fold build-android into the same Ubuntu matrix leg
- Use --no-daemon consistently; drop the redundant --stop steps
- Raise timeout to 60 min to cover the combined work
Two on-disk layout changes that only affect freshly-created data-dirs (no
migration shim since `amethyst-cli-data` was never tagged or released):
- Default `--data-dir` is now `./amy` instead of `./amethyst-cli-data` —
shorter, matches the binary name, and still overridable via the flag or
`$AMETHYST_CLI_DATA`.
- MLS-only files (`groups/` and `keypackages.bundle`) move under a new
`marmot/` subdirectory so the top-level root stays tidy as more
non-Marmot state lands (notes, profile caches, etc). Existing
top-level files (`identity.json`, `relays.json`, `state.json`) are
unchanged.
README tree diagram and DEVELOPMENT.md test table updated to match.
Moves the top-level `post` and `feed` verbs under a single `notes` group
(`amy notes post`, `amy notes feed`). Keeps the per-event-family grouping
consistent with `profile` (kind:0), `dm` (NIP-17), and `marmot` (MLS).
PostCommand and FeedCommand are unchanged — the new NotesCommands object
is a thin dispatcher that routes `notes post` / `notes feed` into them.
Adds three new top-level verbs to amy that mirror the core social-feed
surface of the Android client:
- `amy profile show [USER]` / `amy profile edit ...` reads and patches the
user's NIP-01 kind:0 metadata. `edit` reuses MetadataEvent.updateFromPast
semantics so unset flags keep prior values and blank values delete the
field, falling back to MetadataEvent.createNew when no kind:0 exists yet.
- `amy post TEXT` publishes a NIP-10 kind:1 short text note to the user's
outbox relays via TextNoteEvent.build.
- `amy feed [--author USER] [--following] [--limit N] [--since|--until TS]`
reads kind:1 notes — own / single-author / contact-list — using
Context.drain with author-scoped filters, dedups by id, and returns the
newest-first window as JSON.
All three keep the thin-assembly-layer rule: no Nostr or business logic in
cli/, every event is built/signed via quartz/ and published through the
existing Context.publish + Context.drain helpers.
The default Event.notifies body was hand-rolling a tag scan for lowercase
`p`, duplicating the tag-shape knowledge that already lives in PTag.
Give PTag ownership of the check:
PTag.isNotifying(tags, userHex) // iterates tags and uses PTag.isTagged
Event.notifies now delegates to it. Kinds that override (CommentEvent,
WakeUpEvent) still work — they compose or replace the default as before.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
Conversation cards had the name (bodyMedium) and last-message preview
(bodySmall) stacked flush against each other with very similar color
weight, so they blurred into one visual block. Now:
- Name: titleSmall (14sp Medium) — the focal top line.
- Preview: bodySmall at onSurfaceVariant.alpha = 0.7 with a 2dp spacer
above so it sits as a clearly secondary line.
- Timestamp: labelSmall at alpha 0.6, with an 8dp gutter before it so
long names don't collide with the time.
- Group badge: onSurfaceVariant.alpha = 0.5 instead of primary-70% so
it doesn't compete with the name for attention.
The name now wins the eye first; the preview reads as a subtitle.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Split the 12dp draggable divider into two 6dp halves: left half takes
surfaceContainer (matching the conversation list pane's fill), right
half takes surface (matching the chat pane's fill). The divider now
reads as the shared edge between the two panes instead of a solid
contrasting stripe cutting down the middle.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
- **Draggable list pane**: the 280dp-fixed conversation list width is
now user-adjustable via a draggable divider between the list and the
chat pane. Width is clamped to 220-480dp and persisted across app
restarts via DesktopPreferences.messagesListWidthDp. Cursor flips to
the horizontal-resize arrow on hover.
- **ConversationCard alignment**: the unread indicator used to sit as
an 8dp dot + 14dp spacer to the left of the avatar, which pushed
every card ~14dp inward from the header row's 12dp padding. The
avatar now starts flush with the header (aligned with the top-bar
buttons); the unread state is drawn as a 10dp dot overlaid on the
avatar's bottom-right corner (iMessage / Slack-style), with a
surface-matched ring so it reads clearly against the avatar.
- **Drafts empty state**: was a flush-left Text pinned 16dp below the
header; now uses the shared EmptyState composable so "No drafts
yet" / description is vertically + horizontally centered in the
available space, consistent with Notifications / Highlights / etc.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Every file under commons/src/commonMain/kotlin/.../commons/ui/chat/
(ChatBubbleLayout, ChatMessageCompose, ChatroomHeader,
DmBroadcastBanner, DmBroadcastStatus, UserDisplayNameLayout) was
imported only by desktopApp. Android has its own parallel
implementation at amethyst/.../chats/feed/, so nothing shared.
Moved the six files into desktopApp/.../ui/chats/ alongside
ChatPane.kt, DesktopMessagesScreen.kt, etc. — same package as every
existing caller, which lets us drop six `import` lines from
ChatPane.kt + DmSendTracker.kt + DesktopMessagesScreen.kt. Empty
commons/ui/chat/ directory removed.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
The notification pipeline previously hard-coded a lowercase-`p`-tag match
in two places (the observer predicate and consumeFromCache via
taggedUserIds). That's correct for most kinds but wrong for two:
- NIP-22 CommentEvent: a comment several levels deep only tags the root
author via uppercase `P` (RootAuthorTag). Pure lowercase-p routing
missed "someone replied deep in your thread" notifications.
- Experimental WakeUpEvent (kind 23903): its `p` tags are the authors of
the subject events it references — Bob reacting to Alice's post yields
a WakeUpEvent with p=Bob, even though Alice's device is the one that
needs to wake up. Transport-layer routing (push/relay subscription)
already delivered the event to the right device, so the in-event
routing has to be permissive.
Introduce `open fun Event.notifies(userHex: HexKey): Boolean` with a
lowercase-`p` default that covers NIP-01/04/17/25/28/34/57/68/71/84/AC/
chess/wiki/long-form/poll mentions. Each kind with distinct semantics
overrides:
- CommentEvent.notifies: super.notifies(u) || rootAuthorKeys().contains(u)
— picks up uppercase P root-author routing on top of lowercase p.
- WakeUpEvent.notifies: true — every logged-in account is a valid wake
target once the event has reached LocalCache on this device.
NotificationDispatcher's observer predicate and EventNotificationConsumer.
consumeFromCache both now ask `event.notifies(accountHex)` instead of
extracting taggedUserIds themselves. The zap path's redundant
isTaggedUser re-check is gone too (it was duplicating what the outer
routing already enforced).
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
- Dropped the "ID: <hex>…" debug footer and the HorizontalDivider that
sat above it. Cards read as finished content now, not dev output.
- The previous design had an inner header+text Column carrying the
clickable modifier, which meant the hover ring only covered part of
the card (a rectangle inside). Switched to Material3 Card(onClick =
onClick, …) so the whole card is the click-surface and the ripple is
clipped to the card's rounded shape by M3 itself.
- Content moved into a `cardBody` lambda reused by both branches of
the `if (onClick != null)` check — non-clickable fallback exists
for in-thread quoted note wrappers.
- Action buttons inside NoteActionsRow have their own clickables that
consume taps before they reach the Card's handler, so tapping
reply / repost / zap still fires only that action, not the Card.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
M3 OutlinedTextField has ~32dp of vertical contentPadding baked in,
so forcing .height(40-44dp) clipped the bodyMedium (14sp, 20dp line-
height) placeholder vertically.
Refactored both Search and Chat inputs to BasicTextField wrapped in
OutlinedTextFieldDefaults.DecorationBox, which lets us override
contentPadding to (12.dp horizontal, 8.dp vertical). The field now
renders at a true 40dp tall with the placeholder centered and no
clipping. Border, focus states, placeholder, and leading/trailing
icons still come from M3 so the look stays consistent.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
"Edit Profile" was an OutlinedButton with icon + text which rendered
at ~40dp tall — taller than the 32dp IconButtons every other screen
uses in its header row, so it was pushing the Profile title down and
inflating the top border of the whole page.
- Edit Profile → IconButton(size = 32.dp) with Edit icon (size = 20.dp)
tinted primary. Matches the + / refresh / relays pattern on Home,
Reads, Notifications, etc.
- Follow / Unfollow button (when viewing someone else's profile) —
kept as a labelled Button for clarity but compacted to height 32.dp
with contentPadding(horizontal = 12.dp, vertical = 0.dp) so it
matches the header row's scale.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
NoteCard hover (header+text inner clickable + avatar/name chip):
- Clip(RoundedCornerShape(8.dp)) before .clickable on the header+text
Column so the ripple rounds instead of cutting a hard rectangle.
- Avatar+name chip gets a stadium clip (RoundedCornerShape(100.dp))
before its clickable — matches how Slack / Notion / Linear render
user-pill hover states.
Chat bubble hover (ChatBubbleLayout):
- combinedClickable was applied outside the Surface's shape clipping,
so the ripple ignored ChatBubbleShapeMe/Them. Moved the shape into a
named val and added Modifier.clip(bubbleShape) ahead of
combinedClickable. Hover now rounds with the bubble.
Chat reaction icon (ChatPane detailRow):
- On hover the "add reaction" icon used to conditionally appear and
shift every other element in the detail row, causing the whole list
to reflow up or down. Wrapped in a Box with Modifier.alpha that
fades in/out — the icon is always laid out so the row stays fixed.
Button is disabled when not hovered so it's click-through when
invisible.
Search text field:
- Now padded by LocalReadingSidePadding so it stays inside the 720dp
reading column on wide displays (previously the search Row's
fillMaxWidth spanned the whole window after the ReadingColumn
refactor).
- Bumped height from 40dp → 44dp. M3 OutlinedTextField has ~16dp of
internal vertical content padding that was clipping the bodyMedium
placeholder at 40dp. Same change for the chat input.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
The widthIn cap confined every scrollable element to a 720dp column in
the middle of the window. Anywhere the mouse hovered outside that
column, the scroll wheel did nothing — scroll events only reached the
LazyColumn when the cursor was inside.
Refactored ReadingColumn to use BoxWithConstraints + a CompositionLocal
(LocalReadingSidePadding) instead of a width-capping Box wrapper. The
outer Column now fills the whole window so scroll gestures land on the
scrollable wherever the mouse is. Each screen reads the side padding
from the CompositionLocal and applies it to:
- its header Row as `horizontal = readingHorizontalPadding()`
- its LazyColumn as `contentPadding = PaddingValues(horizontal = readingHorizontalPadding())`
Items still appear centered at DefaultReadingWidth (720dp), but the
scrollable surface now spans the full window width.
Also:
- Search placeholder shortened ("Search people, tags, notes…") so it
stops getting cut off in the compact 40dp field.
- UserProfile's floating header AnimatedVisibility call needed to be
fully-qualified (androidx.compose.animation.AnimatedVisibility) to
avoid the compiler picking the ColumnScope overload inherited from
ReadingColumn's outer ColumnScope — the inner BoxScope's
Modifier.align(Alignment.TopCenter) required the non-scoped variant.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Text fields:
- Chat input (ChatPane) and Search field (SearchScreen) both used
M3 OutlinedTextField defaults, which has a 56dp min-height tuned
for mobile touch. On desktop that reads as "way too tall" — roughly
double what Slack / Raycast / VS Code show. Overrode with
Modifier.height(40.dp) / heightIn(min = 40.dp), paired with
bodyMedium (14sp) textStyle and slightly smaller leading/trailing
icons so the field sits at a desktop-native ~40dp when empty, still
grows with content in the chat case.
Settings section headers — normalized to titleSmall (14sp) so every
section sits consistently under the "Settings" titleMedium (16sp)
screen title:
- "Wallet Connect (NWC)" (was titleLarge, already fixed)
- "Relay Settings" (was titleLarge, already fixed)
- "Tor" (was titleLarge, now titleSmall)
- "Media Servers (Blossom)" (was titleMedium, now titleSmall)
MediaServerSettings extra border removed — the parent
RelaySettingsScreen already provides a 12dp horizontal gutter, but
MediaServerSettings was adding another 16dp all-sides padding on top,
showing as a visible inner frame. Dropped that wrapper padding.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Three related fixes:
ReadingColumn modifier ordering:
- fillMaxSize().widthIn(max = 720) was locking width to parent before
widthIn could cap it, so the cap had no effect in practice. Switched
to widthIn(max = 720).fillMaxWidth().fillMaxHeight() which now caps
correctly on wide displays. Same fix applied to the three screens
that use Box+widthIn inline (UserProfile, Search, Settings).
Platform-aware Material Symbols weight:
- ProvideMaterialSymbols now takes an optional weight parameter that
defaults to MaterialSymbolsDefaults.WEIGHT (300) so Android keeps
current behavior. Desktop passes PlatformIconWeight.current which
maps:
macOS → 200 (thin, matches SF Symbols stroke)
GNOME → 300 (matches libadwaita symbolic icons)
KDE → 300 (matches Breeze)
Windows → 400 (matches Fluent Icons)
other → 300
Settings section hierarchy:
- "Wallet Connect (NWC)" and "Relay Settings" section headers dropped
from titleLarge (22sp) to titleSmall (14sp) so they're smaller than
the "Settings" screen title (titleMedium 16sp). Restores the
visual hierarchy the user reported inverted.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Material3 Card has a built-in onClick parameter that handles the
ripple with proper rounded-corner clipping. Using Modifier.clickable
on the Card's modifier bypasses that — the ripple renders as a
rectangle and gets awkwardly cut off at the card corners.
Converted to the onClick-parameter form on:
- LongFormCard (Reads)
- DraftCard (Drafts)
- RelayMetricCard (Relay Dashboard / Monitor tab)
NoteCard intentionally left unchanged: its inner clickable covers
only the header+text region (not the action-buttons row), so a
full-card onClick would conflict with the per-action handlers.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Introduces ReadingColumn, a top-level scaffold that caps feed/list
screens at 720 dp and centers them — matches the Twitter/Mastodon
desktop pattern so cards don't stretch disproportionately on 4K
displays. Applied to: Home, Reads, Notifications, Bookmarks, Drafts,
Highlights, Search, Thread, Profile, Settings. Messages stays
full-width (its own two-pane sizing). Chess, Relay Dashboard, Article
Reader/Editor keep their current full-width layouts (tools/reading
logic dictates width independently).
Header consistency:
- Bookmarks / Drafts / Highlights / Search now have a minimum header
row height of 48dp so screens without action buttons sit at the
same visual weight as screens with IconButtons.
- Drafts' "New Draft" button converted from text Button to IconButton
for consistency with the other screens' icon-only actions.
- Settings title switched from headlineMedium to titleMedium and
wrapped in the standard h=12/v=8 header row.
Profile back button:
- UserProfileScreen now takes canGoBack: Boolean = false. The back
arrow only renders when stacked onto a nav stack (clicking a user
in the feed / notifications). Top-level "My Profile" accessed via
the nav rail has no back arrow — nothing above it to pop.
- Applied to both the in-header back button and the floating
scroll-aware header that appears when scrolling through posts.
Home cards:
- NoteCard switched from surfaceVariant fill (gray) to surface (white)
+ 1dp elevation, matching LongFormCard on Reads. The subtle shadow
gives the feed the same lift as the articles screen.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Content cards were butting up against the nav rail / window edge.
Added contentPadding = PaddingValues(horizontal = 12.dp) to every
scroll container so items sit inset by the same amount the header
row already uses — a consistent gutter the eye can anchor to.
Applied to: FeedScreen, ReadsScreen, NotificationsScreen,
BookmarksScreen, DraftsScreen, SearchScreen, MyHighlightsScreen,
UserProfileScreen, ThreadScreen, ChessScreen, RelayMetricsTab.
RelayConfigTab (verticalScroll Column) got the same horizontal
padding via its content modifier.
Messages is untouched — its two-pane layout with an internal
VerticalDivider already provides its own visual separation.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo