Every event Amy observes — drained from a relay subscription,
unwrapped from a NIP-59 gift wrap, or generated locally for publish —
now flows through Context.verifyAndStore: NIP-01 id + signature check
via Event.verify(), then store.insert(). Bad events are dropped with
a stderr log and never reach command code; persistence failures are
logged but do not propagate, so a broken store never breaks a relay
subscription.
- Context.drain() now persists every received event before surfacing
it to the caller. Existing callers (FeedCommand, DmCommands,
ProfileCommands, Marmot sync) get caching for free.
- Context.publish() persists outbound events too, so the local store
reflects what Amy has done even when every relay rejects.
- Three cache-first read helpers expose the most common lookups
without hitting relays: profileOf(pubKey) → kind:0,
relaysOf(pubKey) → kind:10002, contactsOf(pubKey) → kind:3.
- Class doc on Context spells out the contract; README.md gets a new
"Local event store — the source of truth" section pointing at the
on-disk layout and the design plans.
- DataDir.eventsDir → "<root>/events-store" (avoids colliding with the
marmot file name conventions).
- Context.store: lazy IEventStore over that directory. Lazy so the
many existing commands that don't touch persistent event state pay
zero open cost (no .lock, no seed file). Closed by Context.close()
only when the lazy delegate has actually been initialised — checked
via reflection so close() never accidentally forces it.
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
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.
Delete() returning false on a still-existing file meant we silently lost
scratch state for MLS group / key-package / message stores. Route the
four call sites through a `deleteOrWarn` helper that logs via the
KMP-safe quartz Log when the file refuses to disappear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the harness move from tools/marmot-interop/ to cli/tests/,
three doc locations were still describing the old layout:
- cli/DEVELOPMENT.md § Testing — the "round-trip" row pointed at a
non-existent `cli/src/test/resources/scripts/`, and the "interop
with other clients" row claimed it was out of scope. Both now name
`cli/tests/marmot/` and `cli/tests/dm/` with what each covers, and
the interop-script template points at the concrete test files
rather than re-inventing a smaller example.
- cli/README.md — the "For an interop-test script template" pointer
now links to `cli/tests/README.md` alongside the DEVELOPMENT.md
section.
- .claude/skills/amy-expert/SKILL.md — the "Where things live" tree
omitted the new `tests/` subtree entirely. Added it with a
one-line description per suite. Also extended the wire-up checklist
with a step 7: add a harness case when a new verb changes observable
wire behaviour.
No content changes elsewhere; just path corrections.
Two fixes needed to make the DM harness actually pass end-to-end:
1. Bind the loopback relay to 127.0.0.2 instead of 127.0.0.1.
Quartz's `RelayTag.parse` (called from `ChatMessageRelayListEvent.
relays()`) rejects any URL for which `isLocalHost()` returns true —
the substring check matches `"127.0.0.1"` among others. That means a
kind:10050 event whose `relay` tag reads `ws://127.0.0.1:8090/`
silently parses to an empty relay list, and `RecipientRelayFetcher`
reports `dmInbox == []`. Under strict mode `dm send` then correctly
refuses with `no_dm_relays` — hiding an otherwise-valid test setup.
`127.0.0.2` is the cleanest fix: still pure loopback (no network
traffic, no config needed), not matched by `isLocalHost()`, and
Linux binds any IP in 127.0.0.0/8 without setup. The Marmot harness
is unaffected because its tests never depend on parsing their own
kind:10051/10002 relay tags back out — they hit the bootstrap
fallback path.
marmot/setup.sh's `start_local_relay` now reads `${RELAY_HOST:-
127.0.0.1}` so both harnesses can share it; the DM harness sets
`RELAY_HOST=127.0.0.2` by default and exposes `--host` as an escape
hatch.
2. Replace dm-06 cursor-advance check with `--since` filter check.
The original assertion ("second no-flag `dm list` returns 0") was
wrong for the actual design: `filterGiftWrapsToPubkey` always
subtracts a 2-day lookback from `since` (required to catch NIP-59's
randomised `created_at`), so a repeat no-flag query legitimately
re-pulls everything in the last two days. The cursor advances the
scan window for efficiency, not for idempotency. dm-06 now verifies
`--since` actually filters: query with `--since <newest + 2 days +
1 hour>` — after the filter's lookback subtraction, the effective
floor lands past every message, and the list comes back empty.
Also: added `protoc` to the DM preflight checks so the missing-dep
error comes before 4 failed cargo retries.
Result on this machine: 6/6 pass, two clean runs in a row.
Adds two inner-event verbs on top of the existing kind:9 text send path:
amy marmot message react GID TARGET_EVENT_ID EMOJI
amy marmot message delete GID TARGET_EVENT_ID...
Both build an unsigned rumor per MIP-03 (RumorAssembler) and wrap it in
the usual kind:445 outer via MarmotOutboundProcessor — the same shape as
text messages, just a different inner kind. The target event is looked
up in the account's decrypted inner-message log (persisted by the
inbound pipeline) so the NIP-25 / NIP-09 templates can populate the
target's pubkey and kind tags without making the caller re-derive them.
Test 09 previously noted that react round-trip verification was blocked
on a missing CLI verb. Expanded it to react via amy and confirm B sees
the kind:7 surface in its messages stream.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
`tools/` is for libraries (arti-build); shell-based interop harnesses
that drive the `amy` binary belong with the CLI module. New layout:
cli/tests/
├── lib.sh # shared logging + result tracking
├── headless/helpers.sh # shared amy_a / amy_json / assertions
├── marmot/ # MLS group-messaging interop (vs whitenoise-rs)
│ ├── marmot-interop.sh
│ ├── marmot-interop-headless.sh
│ ├── setup.sh
│ ├── tests-create.sh
│ ├── tests-manage.sh
│ ├── tests-extras.sh
│ └── patches/
└── dm/ # NIP-17 DM interop (amy ↔ amy)
├── dm-interop-headless.sh
├── setup.sh
└── tests-dm.sh
Per-suite changes:
- Each suite owns its own setup.sh; previously the Marmot setup was at
headless/setup.sh and the DM setup at headless/setup-dm.sh, which
implied shared infra they don't actually share.
- `cli/tests/lib.sh` and `cli/tests/headless/helpers.sh` are the only
shared bits across suites.
- The DM harness still reuses Marmot's `start_local_relay` /
`stop_local_relay` (sourced via `../marmot/setup.sh`) — same relay
binary, no duplication.
- All sourcing paths updated; REPO_ROOT now climbs three levels (was
two when the harness lived under `tools/`); patches/ is now a sibling
of marmot/setup.sh rather than under marmot/headless/.
- `.gitignore` updated to cover both suites' state dirs.
cli/ROADMAP.md and cli/tests/README.md updated to point at the new paths.
No behavioural change — every test runs the same code; only the file
layout and source paths moved.
Mirrors the Android Danger-Zone action: wipes every MLS group, retained
epoch secret, persisted KeyPackage bundle, relay subscription, and the
run-state since-cursors. Does not broadcast any SelfRemove/leave
commits, because the reset path is for recovering from corrupted or
unrecoverable local state where graceful teardown may be impossible.
Requires an explicit --yes to execute. Without --yes it emits a dry-run
JSON listing the groups that would be wiped and exits 2, so scripts
that forget the flag fail loudly instead of nuking state.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
Adds `tools/marmot-interop/dm-interop-headless.sh` — a zero-prompt
end-to-end harness that runs two independent `amy` processes against
a loopback nostr-rs-relay and verifies the NIP-17 DM surface.
Six scenarios:
dm-01 text round-trip (kind:14) in both directions
dm-02 `dm list` surfaces prior exchange with `type:text` discriminator
dm-03 strict kind:10050 refuses sends to an inboxless recipient
(surfaces the `no_dm_relays` error JSON)
dm-04 `--allow-fallback` opts into NIP-65 read / bootstrap chain
(asserts a `relay_source: "bootstrap"` or `"nip65_read"` row)
dm-05 file message reference mode (kind:15) — send-file with
--key / --nonce, verify recipient decodes url + key + nonce + mime
dm-06 no-flag `dm list` advances `state.giftWrapSince`; second call
returns an empty `messages` array
Shape matches the existing Marmot harness: reuses `lib.sh` for logging
+ result tracking, reuses `setup.sh` for the nostr-rs-relay lifecycle
(`start_local_relay` / `stop_local_relay`), adds a slim `setup-dm.sh`
preflight (amy + relay only, no whitenoise-rs / Marmot patches), and
defines tests in `tests-dm.sh`.
No new CI job yet — the relay build needs Rust + ~3 minutes on a
cold cache, same constraint as the Marmot harness.
Upload-mode (`dm send-file --file PATH --server URL`) is not scripted
here: it needs a local Blossom server, which is out of scope for this
pass. The shared upload classes are unit-tested on desktop at
`desktopApp/src/jvmTest/kotlin/.../service/upload/`.
`dm send-file` now has two modes:
- **Upload mode** — `dm send-file RECIPIENT --file PATH --server URL`:
generate a fresh AES-GCM cipher, encrypt the file, upload the
ciphertext to the Blossom server, then publish a kind:15 referencing
the returned URL. Auto-detected metadata (encrypted hash + size,
original sha256, mime type, image dimensions, blurhash) is folded
into the kind:15 tags. The response surfaces the encryption key and
nonce so the same encrypted blob can be re-shared without
re-uploading.
- **Reference mode** — the previous shape still works:
`dm send-file RECIPIENT URL --key HEX --nonce HEX [...]`. Useful
when the upload happened elsewhere (Android client, third-party
tool) or for replaying a previous upload.
Mode is selected by the presence of `--file`. Both paths share the
same wrap-and-publish pipeline (`publishWraps`) and respect the strict
NIP-17 §6 relay rule: kind:1059 only reaches the recipient's kind:10050
unless `--allow-fallback` is passed.
Implementation entirely reuses the upload helpers just promoted to
`commons/jvmMain/.../service/upload/`, so no new business logic lives
in `cli/`. The Blossom auth event (kind:24242) is built and signed via
`BlossomAuth.createUploadAuth(... ctx.signer ...)`.
Receive:
- decryptDms now accepts both ChatMessageEvent (kind:14) and
ChatMessageEncryptedFileHeaderEvent (kind:15). The result is a sealed
DecryptedDm hierarchy (TextDm / FileDm) with a `type: "text"|"file"`
discriminator in JSON. File messages emit url, mime_type,
encryption_algorithm, decryption_key, decryption_nonce, hash,
original_hash, size, dim, blurhash. `dm await --match X` searches the
text body for kind:14 and the URL for kind:15.
Send:
- New `dm send-file RECIPIENT URL --key HEX --nonce HEX [...]` verb.
Builds a kind:15 ChatMessageEncryptedFileHeaderEvent via the existing
ChatMessageEncryptedFileHeaderEvent.build + AESGCM(key, nonce), then
publishes through NIP17Factory.createEncryptedFileNIP17. The file is
expected to already be uploaded; carrying the upload itself in `amy`
is the next change.
Spec compliance (NIP-17 §6, "if no kind:10050, shouldn't try"):
- resolveDmRelays now returns kind:10050 only by default. If the
recipient has none, send/send-file exit with `no_dm_relays` and a
message recommending `--allow-fallback`. The fallback chain (kind:10002
read marker → bootstrap) is preserved behind that opt-in flag for
interop tests and brand-new accounts.
Each recipient row in the send response now includes `relay_source`
("kind_10050" / "nip65_read" / "bootstrap") so callers can tell where
the wraps actually went.
Note: Quartz already enforces the NIP-17 §7 step-3 impersonation guard
(seal pubkey is forced over the rumor's claimed pubkey inside
`Rumor.mergeWith`), so no extra check is needed in the CLI.
Three call sites did the same two-layer peel —
kind:1059 → (nip44 decrypt) → kind:13 sealed rumor → (nip44 decrypt) → rumor
Extracted as `GiftWrapEvent.unwrapAndUnsealOrNull(signer)` in
commons/relayClient/nip17Dm/, and collapsed:
- commons/marmot/MarmotIngest.kt — was an inline `when` switch; now a
one-liner. Behaviour unchanged (still passes through if the inner
isn't a seal, matching the previous defensive `else -> inner` branch).
- desktopApp/Main.kt — dropped the nested unwrap/unseal block in the
kind:1059 case of the LocalCache dispatcher.
- cli/DmCommands.kt — replaced the manual chain in `decryptChatMessages`.
Android's DecryptAndIndexProcessor keeps its two separate handlers
(processNewGiftWrap / processNewSealedRumor) because the LocalCache
pipeline re-enters on each peel — that's a different shape and not
touched.
No behaviour change on any platform.
Adds three verbs wired into the top-level dispatch:
- `amy dm send RECIPIENT TEXT` — builds a kind:14 ChatMessageEvent,
runs it through NIP17Factory to seal and gift-wrap, then publishes
each wrap to its recipient's DM inbox (kind:10050, fallback kind:10002
read, final fallback bootstrap). Emits one JSON line with the inner
kind:14 id, per-recipient wrap ids, and per-relay ACK status.
- `amy dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]`
— drains gift wraps addressed to us from our inbox relays (or outbox /
bootstrap fallbacks), unwraps+unseals each one, dedupes by inner id,
and returns them oldest-first. With no flags it advances the
giftWrapSince cursor in state.json (matches the Marmot syncIncoming
convention); with --peer/--since it's a stateless query.
- `amy dm await --peer NPUB --match TEXT [--timeout SECS]` — polls the
same drain on a 2s tick until a DM from NPUB contains TEXT. Timeout
exits 124 like the other await verbs.
All three reuse Quartz entirely (NIP17Factory, GiftWrapEvent,
SealedRumorEvent, RecipientRelayFetcher); the only shared piece we
needed in commons — filterGiftWrapsToPubkey — was extracted in the
previous commit. No business logic leaks into cli/.
Plan: cli/plans/2026-04-23-nip17-dm.md.
Design doc for `amy dm send|list|await`, reusing the existing Quartz
gift-wrap (NIP-17/NIP-59/NIP-44) pipeline. Single file under
cli/plans/; extracts FilterGiftWrapsToPubkey from amethyst/ to commons/
as a prerequisite commit so the CLI can depend on it without pulling
in :amethyst.
The first pass only fixed `group add`. Auditing the rest of the CLI
against MIP-00..03 turned up three more spots where `amy` either
queried the wrong relays or silently skipped a required advertisement:
1. `marmot key-package check <npub>` used `anyRelays()` — i.e. the
inviter's configured relays — so checking for a KeyPackage on a
user who advertises `kind:10051` somewhere we don't know about
always returned `not_found`. Now runs RecipientRelayFetcher against
bootstrap seeds and fetches from the union of (target kind:10051,
target kind:10002 write, bootstrap). Emits `found_on` so callers
can see which relay served the hit.
2. `await key-package <npub>` had the same bug inside the poll loop.
Resolved once up front, then the loop fetches from the target's
advertised relays every tick. Throws an `AwaitTimeout` early if no
relays can be discovered at all, instead of silently polling void.
3. `relay publish-lists` published kind:10002 + kind:10050 but never
kind:10051. Per MIP-00 the KeyPackage Relay List is how other
Marmot clients discover where our KPs live — without it the
`key_package` bucket on disk is invisible to anyone else. Now also
publishes kind:10051; falls back to the NIP-65 set if the bucket
is empty so we never advertise an empty list. (`amy create`
already publishes it via AccountBootstrapEvents.)
Docs: cli/README adds a "Relay routing" section that lists the exact
relay set used for publish vs fetch of every Marmot event kind, plus
the bootstrap-pool definition, so agents + interop-test authors can
reason about cross-user reachability without reading the code.
`amy marmot group add` previously sent the Welcome gift wrap to the
inviter's own kind:10050 (ctx.inboxRelays()) and fetched the invitee's
KeyPackage from only the inviter's configured relays. Two users with
disjoint relay configurations could never successfully marmot each
other — the welcome landed somewhere the invitee never polled.
Mirror the Android Account.addMarmotGroupMember flow in the CLI:
- New RecipientRelayFetcher in quartz/marmot one-shot-drains a target
user's kind:10050, kind:10051 and kind:10002 from a seed relay set.
Replaceable-event semantics: newest created_at per kind wins. Guards
against relays echoing events authored by someone else.
- Context.bootstrapRelays() unions the inviter's configured relays with
AmethystDefaults (DefaultNIP65RelaySet + DefaultDMRelayList) so we can
discover strangers even when nothing overlaps with our own config.
- GroupAddMemberCommand now per-invitee: looks up their relay lists;
passes kind:10051 + kind:10002 write + bootstrap to KeyPackageFetcher;
publishes the welcome to kind:10050 (fallback to NIP-65 read, then
DefaultDMRelayList, then our outbox as belt-and-braces).
Group commits/messages (kind:445) continue to use the group's MIP-01
relay set — those were already correct. Exposes welcome_targets and
key_package_relays in the JSON output so callers can verify routing.
The interop harness prints `[cli] ingest <kind>/<id> via <relay> -> Failure`
for any commit/proposal/app message that quartz can't process. Without
the error string it's impossible to tell `confirmation_tag mismatch` from
`parent_hash invalid` from `commit references unknown proposal` without
reading the Kotlin stack. Append `result.message` for Failure outcomes so
the harness log is self-contained when diagnosing quartz→quartz errors
(the openmls side already has `{e:?}` via the mdk-core patch).
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Five protocol-level fixes and a batch of harness correctness fixes to get
the headless Marmot/Whitenoise interop harness from 1/13 to 5/13 passing
cleanly, with the remaining failures all rooted in wn's per-account
serial event-processor retry backoff (which drops undecryptable
pre-membership commits after several minutes) rather than amy behaviour.
quartz + commons
----------------
* MarmotGroupData: hold CURRENT_VERSION at 2. mdk-core (the Rust MLS
engine used by whitenoise-rs) strict-rejects v3 payloads with
`ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`
— our v3 welcomes and GCE commits never apply, so every cross-client
group flow dies at welcome processing. We still parse v3 happily on
the way in; we just don't emit it until mdk publishes the
forward-compat fix MIP-01 mandates.
* MarmotManager.updateGroupMetadata: MERGE extensions instead of
REPLACING. RFC 9420 §12.1.7 says GCE proposals blow away the old
extension list; callers that pass only [marmot_group_data] dropped
[required_capabilities], which peers then reject. Preserve every slot
except the one we're updating.
* MarmotManager.createGroup: new optional `initialMetadata` parameter
that bakes MarmotGroupData into epoch-0 GroupContext.extensions
directly. Without it, creators had to publish a pre-membership
"bootstrap" commit that no later joiner could decrypt — each such
peer then burned their retry budget on an undecryptable kind:445
before seeing the real state. Threaded through MlsGroup.create /
MlsGroupManager.createGroup.
* MarmotManager.mlsGroupIdHex: new translation helper so any code
juggling the MIP-01 nostr_group_id (what amy indexes on) and the
MLS GroupContext groupId (what mdk indexes on) can cross-reference
them without reaching into MlsGroupManager directly.
amethyst module
---------------
* Account.leaveMarmotGroup: self-demote before SelfRemove per MIP-01,
and promote a surviving member to admin first if the caller is the
sole admin (otherwise we'd throw "admin depletion"). Matches the
cli/GroupMembershipCommands.leave flow.
cli (amy)
---------
* Context.syncIncoming: don't advance `giftWrapSince` on empty polls
(so the first-ever sync doesn't bump the cursor past every
past-timestamped wrap we've ever been sent), subtract 2 days lookback
when filtering (NIP-59 randomWithTwoDays gift wraps can have any
createdAt in the last 48h), and only advance `groupSince` for groups
we actually received events for.
* Context.syncIncoming: after ingest, if any Welcome consumed a
KeyPackage, rotate and publish a fresh one immediately. MIP-00
requires this — a KP can only be welcomed once and leaving the
consumed one on relays just means later senders invite us with a
bundle we no longer have private keys for.
* Context.resolveGroupId: accept either nostr_group_id (amy's primary
key) or the MLS GroupContext groupId (what wn emits) on every verb
that takes a group id. Wired through GroupAdd/Remove/Leave/Metadata
/Read, Message send/list, and all the await* verbs so harness
scripts never have to juggle both forms for a single group.
* GroupCreateCommand: bake initial metadata into epoch 0 (see the
quartz change above). Dropped the now-redundant bootstrap commit
publish and tightened the JSON output to include `mls_group_id`.
* GroupMembershipCommands.leave: self-demote admin before SelfRemove;
promote an heir if we're the only admin, otherwise the GCE would
deplete admins and the leave aborts.
* MarmotIngest.ingestGiftWrap: unwrap the sealed-rumor layer. NIP-59
wrap is gift-wrap(kind:1059) → seal(kind:13) → rumor; the old code
only unwrapped once and then checked `inner.kind == 444`, which is
always false because inner is actually the seal. Unseal once more
before the Welcome check. This single fix is what unsticks every
amy-side Welcome ingestion.
wn harness patches + scripts
----------------------------
* whitenoise-defaults-env.patch: honour $WHITENOISE_DISCOVERY_RELAYS in
`Relay::defaults()` (release builds otherwise bake damus.io / primal
/ nos.lol into every new account's NIP-65 / Inbox / KeyPackage
lists, which breaks publishing and prevents the inbox subscription
plane from ever reaching an operational state in a sandbox).
* setup.sh: sleep 2s after amy's initial kind:30443 publish so
nostr-rs-relay has a chance to fsync before wn's first targeted
discovery query. Without it wn's `keys check` races the relay's
WAL flush and intermittently returns NotFound.
* lib.sh: peel wn's `{"result": …}` wrapper in `jq_group_id`,
`wait_for_invite`, `wait_for_message`, `wait_for_member`. Post-v0.2
wn `--json` output nests everything under `.result` (and
`groups invites[]` nests further under `.group.mls_group_id`) —
these helpers were still pattern-matching on the flat shape, so
they returned empty strings for a perfectly good response.
* tests-{create,manage,extras}.sh: track both group IDs per test
(amy's nostr + wn's MLS), pass each CLI the id it understands, and
bump the post-commit wait timeouts to 90–120s so wn's exponential
retry backoff has time to work through the pre-membership commits
it can't decrypt and get to the ones it can.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Add cli/README.md covering the JSON-output contract, install via
installDist, today's command surface (identity / relays / marmot),
and use from agents + interop tests. Add cli/DEVELOPMENT.md with the
feature-parity matrix vs Amethyst, the extract-from-Android recipe
(amethyst/ -> commons/ -> cli/), command template, EventRenderer
plan shared with Desktop, testing strategy focused on what quartz
and commons don't already cover, distribution matrix across macOS /
Windows / Linux / Nix / Zapstore, and an ordered 10-step roadmap.
https://claude.ai/code/session_01BQ5ZHwa8BAgEQ9zeM4CKhW
Jackson was writing the computed \`hasPrivateKey\` into identity.json
and then refusing to read it back ("Unrecognized field hasPrivateKey")
on the next invocation. All amy commands that reload the data-dir
(\`marmot group create\`, \`marmot key-package publish\`, …) exited 1 at
the Context.open step. Pure boolean getter, no persistence needed.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Move the "defaults a new Amethyst account gets seeded with" into commons
so the UI and amy agree byte-for-byte on what a fresh account looks like.
commons additions:
- commons/defaults/Constants.kt — relay URL constants (moved from amethyst/)
- commons/defaults/AmethystDefaults.kt — DefaultChannels, DefaultNIP65RelaySet,
DefaultNIP65List, DefaultGlobalRelays,
DefaultDMRelayList, DefaultSearchRelayList,
DefaultIndexerRelayList (moved from
amethyst/model/AccountSettings.kt)
- commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name)
that builds the nine events a new Amethyst account publishes: kind:0, 3,
10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth.
Retrofits:
- AccountSessionManager.createNewAccount now delegates event construction to
the commons helper; it still packages them into AccountSettings for the
Android storage path.
- DefaultSignerPermissions stays in AccountSettings.kt — it uses Android
NIP-55 Permission/CommandType types.
- 19 import updates across amethyst/ to point at the new commons paths.
New CLI verbs:
- amy create [--name NAME]: mint a keypair, write identity.json, seed
relays.json with the Amethyst defaults, publish all nine bootstrap events
to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed.
- amy login KEY [--password X]: accept any identifier form Amethyst's login
screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic
(NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey
(with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys
land with privKeyHex=null; Identity now carries that cleanly.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Five extractions so the Amethyst UI and the new amy CLI share one
implementation for each piece of Marmot/identity behaviour instead of
reimplementing it per platform.
quartz additions:
- MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the
metadata shape every inviter stamps on a fresh group and the outbox-
merge rule every admin commit carries forward.
- KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051,
target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish-
side resolveKeyPackagePublishRelays.
- resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex
/ NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client
infra so NIP-05 resolution is live over HTTP.
commons additions:
- MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by
every removeMember caller.
- MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays):
convenience overload that lifts the base64 decode into the manager.
- MarmotManager.buildTextMessage(..) returning a TextMessageBundle;
optionally persists the outbound inner event (CLI needs persist,
Amethyst relies on LocalCache loopback).
- MarmotIngest.ingest(event): routes kind:1059 / kind:445 through
unwrap -> processWelcome / processGroupEvent -> persist the decrypted
application message. Deliberately platform-agnostic.
Retrofits:
- Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take
a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates
to KeyPackageFetcher.
- AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged.
- AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via
buildTextMessage(persistOwn = false) to avoid double-persisting.
- AccountSessionManager's NIP-05 login branch delegates to
resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get.
- CLI Context exposes nip05Client and requireUserHex; syncIncoming now
calls MarmotManager.ingest; all command sites use KeyPackageFetcher
+ the shared identifier resolver. Npubs util deleted.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
New :cli JVM module producing an `amy` binary that drives Amethyst
functionality headlessly. Identity and relay configuration sit at the
root (`amy init`, `amy whoami`, `amy relay …`); Marmot/MLS lives under
`amy marmot …` so future verbs (dm, feed, profile) can slot in cleanly.
Marmot surface covered:
- key-package publish / check
- group create / list / show / members / admins / add / rename /
promote / demote / remove / leave
- message send / list (kind:9 inner events)
- await key-package / group / member / admin / message / rename / epoch
(non-interactive polling with --timeout; exit 124 on timeout)
Wiring:
- NostrClient + BasicOkHttpWebSocket.Builder, reusing the existing
publishAndConfirmDetailed and fetchFirst accessories
- MarmotManager from :commons with file-backed MlsGroupStateStore,
KeyPackageBundleStore, and MarmotMessageStore (unencrypted — scratch
harness use only)
- each invocation is one-shot: prepare → syncIncoming → run command →
persist → disconnect
All commands emit one JSON object on stdout; diagnostics on stderr.
Designed so shell harnesses can pipe through jq without bespoke parsing.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq