Commit Graph
290 Commits
Author SHA1 Message Date
Claude 9fcf85bed0 fix(quartz/sqlite): serialise writes via a Room-style connection pool
androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore
shared a single lazy connection across all callers, so two coroutines
calling insertEvent() at the same time would race on BEGIN IMMEDIATE
and the modules' prepared statements, surfacing as
"cannot start a transaction within a transaction" or SQLITE_MISUSE.

Mirror Room's design: introduce SQLiteConnectionPool with one writer
connection guarded by a coroutine Mutex and N reader connections
handed out via a Channel-as-semaphore (file-backed DBs only; in-memory
DBs share the writer because each ":memory:" connection is a separate
DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore
+ LiveEventStore to suspend, route writes through useWriter and reads
through useReader. RelaySession now launches handleEvent / handleCount
on its scope. CLI Context helpers and StoreCommands.sweepExpired pick
up suspend.

Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200
inserts, parallel reads alongside writes, transaction batches across
coroutines, and a reopen smoke test all pass against a file-backed DB.

https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
2026-04-26 13:25:30 +00:00
Claude e17ef42e54 fix(cli): rename global --name to --account to free --name for subcommands
The marmot harness surfaced the bug on first run: amy stripped
`marmot group create --name "Interop-02"` thinking the global
account selector ran into the group's display-name flag. Result:
amy resolved the "Interop-02" account, found no identity.json, and
errored — no group ever got created.

Renames the global account-selector flag to `--account`. The per-
subcommand `--name` flags (`marmot group create --name "Demo"`,
`profile edit --name "Alice"`, `marmot await group --name X`) are
untouched — they're free of the global parser now that it doesn't
claim the same name.

Sweep:
- `Main.kt`: GlobalFlag.NAME → ACCOUNT, long "--account".
- `Config.kt`: DataDir.resolve param renamed nameFlag → accountFlag;
  every error message points the user at --account.
- `UseCommand.kt`: error hint says `amy --account NAME init`.
- All test wrappers + direct $AMY_BIN calls under cli/tests/ swap
  the global `--name X` for `--account X` (subcommand --name kept
  exactly where it appeared).
- README + DEVELOPMENT updated.
2026-04-25 15:26:17 +00:00
Claude 99586fbe7e feat(cli): drop --data-dir; tests isolate via $HOME override
There is no installed base to protect, so the self-contained
`--data-dir P` escape hatch goes away entirely. amy now has exactly
one layout — the production multi-account one — and tests exercise
that same code path. Drops one global flag, one DataDir construction
mode, and the "tests use a different code path than users" footgun.

Layout (unchanged from the previous commits — just the only mode now):

    ~/.amy/
    ├── current                      # `amy use NAME` marker
    ├── shared/
    │   └── events-store/            # FsEventStore, one per machine
    ├── alice/
    │   ├── identity.json
    │   ├── state.json
    │   ├── aliases.json             # {"alice": "<own npub>"} after init
    │   └── marmot/
    └── bob/ ...

`DataDir.DEFAULT_ROOT` reads `$HOME` directly (falling back to
`user.home`) because JDK 21 resolves `user.home` via getpwuid and
ignores `$HOME` — which would have broken the standard CLI test
isolation pattern of `HOME=/tmp/foo amy …` (the same convention
git, gpg, npm follow).

Test sweep:

- `cli/tests/headless/helpers.sh`, `cli/tests/dm/setup.sh`,
  `cli/tests/cache/cache-headless.sh` wrappers all switch to
  `HOME=$STATE_DIR amy --name X …`.
- `ensure_identity_for` drops its `dir` parameter; the function and
  every harness call site go through `--name` only.
- `A_DIR`/`B_DIR`/`D_DIR` get repointed at `$STATE_DIR/.amy/X`. The
  one consumer (`cache-headless.sh`'s T6 `relays.json` check) still
  works since it's just a path probe.
- `cli/tests/dm/tests-dm.sh` ghost identities get their own
  short-lived `$HOME` so they don't pollute the main test root.

Cache-test T4 inverts: pre-shared-store, "B has not seen A → first
profile show is a relay miss, second is a cache hit" tested
per-account caching. With one shared events-store, B's first lookup
of A is already a cache hit because A wrote kind:0 there during
bootstrap. T4 now asserts that — drops the stale "second lookup hits
cache" half. T7 (no-identity maintenance verbs) gets a fresh fake
`$HOME` plus a throwaway `--name` so the empty store has no inherited
events.

Docs (README + DEVELOPMENT) rewritten to match — quick-start now uses
`amy --name alice create`, the on-disk-layout section shows the new
tree, and the global-flags table replaces `--data-dir` with `--name`
plus the new `amy use` verb.
2026-04-25 15:07:31 +00:00
Claude 46bdd59ead feat(cli): account auto-pick + amy use to pin the active account
Resolution order in account mode (when --data-dir is not set):

  1. --name X if given.
  2. ~/.amy/current marker (set by `amy use X`).
  3. Sole subdirectory of ~/.amy/ other than shared/.
  4. Error — disambiguate with --name or `amy use`.

Single-account users skip the flag entirely (`amy whoami` Just Works
once one account exists). Multi-account users either pin one with
`amy use bob` (writes ~/.amy/current) or pass --name on every call.
The pinned account can be overridden by --name on a single command,
and cleared with `amy use --clear`.

`amy use` (no arg) prints the current pin plus the list of available
accounts. `amy use NAME` validates the name, requires the account dir
to already exist (else `no_account` with a creation hint), and
atomically writes the marker.

The auto-pick errors are deliberately self-explaining:
- 0 accounts → "no account at ~/.amy; create one with `amy --name X init`"
- 2+ accounts unpinned → "multiple accounts (alice, bob); pick one
  with --name or `amy use <name>`"
- stale current marker → "current pins 'ghost' but ~/.amy/ghost
  doesn't exist; rewrite with `amy use <name>` or pass --name"

`use` is dispatched before DataDir.resolve so it works even when the
auto-pick would fail — that's the whole point of having the verb.
The `name` field also lands in `whoami` output now (null in
--data-dir legacy mode).
2026-04-25 14:36:54 +00:00
Claude 076859ca2d feat(cli): introduce ~/.amy account-mode layout + --name flag
Default on-disk layout becomes:

    ~/.amy/
    ├── shared/
    │   └── events-store/        (lazy: created on first event)
    └── <account>/               (created by `amy --name X init`)
        ├── identity.json
        ├── state.json
        ├── aliases.json
        └── marmot/

Per-account state moves under `~/.amy/<account>/`; the events-store is
shared across accounts under `~/.amy/shared/`. The shared store is
safe to share for now because amy doesn't currently persist any
decrypted inner events to it (NIP-17 DMs unwrap-and-display in
DmCommands; MLS inner events go to the per-group .log under
<account>/marmot/groups/, not the event store). When that changes,
a follow-up will introduce a per-account private-events-store and a
composite reader.

A new `--name X` global flag selects (or creates) `~/.amy/X/`.
`--data-dir P` is preserved as a self-contained escape hatch — the
test harness and ad-hoc throwaway dirs use it, events-store stays
inside P. Pass exactly one of `--name` or `--data-dir`; passing both
or neither is bad_args (exit 2). Names must match
[a-zA-Z0-9_-]{1,64}; `shared` is reserved.

`amy init --name alice` writes a self-entry into
`<account>/aliases.json` ({"alice":"npub1…"}) so future commands and
the planned `amy alias add` / dm-recipient resolver can refer to the
account by name. The init result map gains a `name` key (null in
legacy mode).

Open question for a follow-up: when only one account exists in
~/.amy/, should `amy whoami` work without --name? Today it doesn't —
strict mode. Also pending: README rewrite, plus a sweep through
commands that print `data_dir` to also surface `name` where useful.
2026-04-25 14:32:19 +00:00
Claude cd0b43afd9 fix(cli): expand embedded JsonNode in text-mode renderer
`profile show` puts the parsed kind:0 content under `metadata` as a
Jackson `JsonNode` (`ProfileCommands.kt:114`). The text renderer only
recursed into `Map`/`List`, so the JsonNode fell through to
`toString()` and printed as a single quoted-JSON line:

    metadata:       {"name":"Alice","picture":"…",…}

Convert any embedded JsonNode to plain Java types via
`mapper.convertValue` once at the top of `renderText`, so the same
generic walk yields:

    metadata:
      name:    Alice
      picture: …
      …

The walk handles nested cases (a hand-built Map containing a JsonNode
subtree, an ArrayNode inside a List, etc.). The `--json` shape is
untouched — Jackson's `writeValueAsString` already serialises JsonNode
natively.
2026-04-25 13:46:55 +00:00
Claude ce3a82ab3d feat(cli): colour amy's text output and humanise scalar values
Defaults to ANSI colour when stdout is a TTY (off when piped, off under
NO_COLOR=…, force-on under CLICOLOR_FORCE=1). Layout improvements that
apply to every command without per-command code:

- Sibling keys at each indentation level pad to the widest, so colons
  line up YAML-style.
- Keys render bold; list dashes and "(none)"/"(empty)" markers render
  dim.
- Booleans render as `yes` / `no` (green / red).
- Integer values under `*_at` keys render as
  `2026-04-25 12:30:45Z (2m ago)` instead of a raw epoch second.
- Integer values under `*_bytes` keys (and `size`) render as
  `7.0 KiB` / `1.2 MiB` / etc.
- Errors render `error: <code>: <detail>` with the prefix bold-red and
  the code yellow.

The `--json` shape and exit codes are untouched; the smart formatting
only changes how scalars surface in the human-text mode.
2026-04-25 13:22:15 +00:00
Claude 03eb7be509 feat(cli): default amy stdout to human-readable text; --json opts in
amy's default stdout is now a YAML-ish render of the underlying result
map; the previous single-line JSON contract moves behind a global
`--json` flag. Errors mirror the same rule (`error: <code>: <detail>`
on stderr by default, JSON `{"error":...,"detail":...}` under --json).
Exit codes (0/1/2/124) and the --json shape itself are unchanged —
only the default presentation flips.

- Replaces Json.writeLine / Json.error with mode-aware
  Output.emit / Output.error. The same Jackson mapper is reused for
  on-disk JSON via Output.mapper.
- Adds `--json` to the global flag set in Main.kt; honoured even when
  argument parsing fails so error JSON keeps shape under --json.
- Updates the test harness wrappers (amy_a / amy_b / amy_d in
  cli/tests/{headless,dm,cache}/) and the few direct $AMY_BIN call
  sites whose stdout is consumed via $() / 2>&1 — they now pass
  --json so the existing jq pipelines keep working.
- Rewrites the README "Output contract" and DEVELOPMENT design
  principles to describe the new default, and clarifies that only
  --json is the public API; the text shape is allowed to drift.
2026-04-25 12:23:37 +00:00
davotoula 6e5a3224e5 minor sonar fixes 2026-04-25 12:23:20 +02:00
Claude 6123dc267d Merge remote-tracking branch 'origin/main' into claude/cli-event-store-database-ef2U2
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt
2026-04-25 04:58:41 +00:00
Claude 7bbfb52d87 feat(cli): amy store stat/sweep-expired/scrub/compact + e2e cache test
Three changes that go together:

1. Reconcile cli/plans/2026-04-24-file-event-store-{overview,nips}.md
   with shipped reality: code lives in quartz/jvmMain/, not commons/;
   data dir is <data-dir>/events-store/, not <root>/events/.

2. New StoreCommands wired as `amy store …`:
     - stat            → events count, kind histogram, disk bytes,
                         oldest/newest createdAt. Pure read, no Context
                         (skips identity check).
     - sweep-expired   → wraps store.deleteExpiredEvents(); reports
                         {swept, remaining}.
     - scrub           → wraps store.scrub() to rebuild idx/ from
                         canonicals.
     - compact         → wraps store.compact() to drop dangling idx/.
   All four open the FsEventStore directly (no Context, no identity
   needed) — they're store-only operations.

3. New e2e harness at cli/tests/cache/cache-headless.sh that boots a
   local nostr-rs-relay, two amy identities (A + B), and asserts:
     T1 — store stat reports non-empty store after publish-lists +
          profile edit, with kind:0 and kind:10002 present.
     T2 — A's `profile show` is `source: "cache"` by default.
     T3 — `--refresh` forces `source: "relays"`.
     T4 — B's first `profile show <A_NPUB>` is a relay miss; second is
          a cache hit (proves drain populates the local store and
          subsequent reads serve from disk).
     T5 — `relay list` reads URLs back from the local kind:10002 /
          10050 / 10051 events.
     T6 — relays.json no longer exists in either data-dir.
     T7 — store stat / sweep-expired / scrub / compact all run
          without an identity present.

Same pattern as cli/tests/dm/dm-interop-headless.sh — reuses the
nostr-rs-relay infrastructure from cli/tests/marmot/setup.sh.
2026-04-25 04:10:10 +00:00
Claude 99be0b2d16 feat(cli): pretty-print event JSON in the local store
Users actually look at <data-dir>/events-store/ files (cat / jq / git
diff), so the CLI now writes them with the InliningTagArrayPrettyPrinter
that quartz already had configured but never invoked. Each event is
indented with 2 spaces, but every tag array stays on a single line —
nice trade-off between human-readable and not-too-tall:

    {
      "id": "...",
      "pubkey": "...",
      "created_at": 1700000000,
      "kind": 1,
      "tags": [
        ["t","nostr"],
        ["e","abc...a"],
        ["alt","quick brown fox"]
      ],
      "content": "hi there",
      "sig": "..."
    }

Stored bytes are not the canonical NIP-01 form — but verification
re-canonicalises via EventHasher anyway, so format is purely a UX
choice. Compact stays the default for any caller that doesn't opt
in (Android keeps SQLite, generic FsEventStore embedders keep
compact).

- JacksonMapper.toJsonPretty(event): new entry point that uses
  writerWithDefaultPrettyPrinter() with the existing inlining printer.
- FsEventStore now takes an `eventToJson: (Event) -> String` callback,
  default = Event::toJson (compact). Used in insert.
- Context wires in JacksonMapper::toJsonPretty.

2 new tests in FsEventToJsonTest pin both formats (compact stays
single-line; pretty round-trips). 117 fs tests green.
2026-04-25 03:56:39 +00:00
Claude 37a4f89178 feat(cli): drop relays.json — kind:10002/10050/10051 in the store ARE the config
The local relay configuration was redundant once the event store
became the source of truth: every account already publishes signed
kind:10002 (NIP-65), kind:10050 (DM inbox), and kind:10051
(KeyPackage relays) events, and Context now reads each set straight
out of the local store.

Removed:
  - data class RelayConfig (nip65/inbox/keyPackage buckets)
  - DataDir.relaysFile / loadRelays() / saveRelays()
  - Context.relays parameter — Context.open() no longer reads JSON
  - CreateCommand.defaultRelayConfig() helper
  - CreateCommand's saveRelays() call (redundant: ctx.publish persists
    the bootstrap events into the store anyway)

Context now serves the four relay accessors from the store with sane
fallbacks:
  outboxRelays()      = relaysOf(self)?.writeRelaysNorm() ?: DefaultNIP65RelaySet
  inboxRelays()       = dmInboxOf(self)?.relays() ?: DefaultDMRelayList.toSet()
  keyPackageRelays()  = keyPackageRelaysOf(self)?.relays() ?: outboxRelays()
  anyRelays()         = union of the three
  bootstrapRelays()   = anyRelays() ∪ DefaultNIP65RelaySet ∪ DefaultDMRelayList

RelayCommands rewritten:
  - `relay add URL --type T` — read existing relay-list event from the
    store, append URL, build + sign + ingest a new event of the
    matching kind. The replaceable slot mechanism replaces the old
    winner atomically.
  - `relay list` — dump URLs from the latest event in each bucket.
  - `relay publish-lists` — broadcast whichever events are present in
    the store; errors out cleanly when none exist (suggests `relay add`
    or `create`).

No migration: existing data dirs that still have `relays.json` will
ignore it. Run `amy relay add` or `amy create` to repopulate. Per
discussion this is a clean break, not a backwards-compat shim.

README + DEVELOPMENT updated to reflect the new on-disk layout (no
more `relays.json`; events-store/ is the relay configuration).
2026-04-25 03:45:12 +00:00
Claude b2bf874c15 feat(cli): cache-first reads across feed, dm, marmot commands
Audit found four commands draining replaceable relay-list events
(kind:10050, 10051, 10002) on every invocation, plus one fetching
the user's own kind:3 contact list. All five are now cache-first.

New Context helpers:
  - dmInboxOf(pk)         → kind:10050  (ChatMessageRelayListEvent)
  - keyPackageRelaysOf(pk)→ kind:10051  (KeyPackageRelayListEvent)
  - cachedRelayListsOf(pk)→ assembles a RecipientRelayFetcher.Lists
                             from the local store; returns null if
                             nothing is cached so callers can fall
                             back with `?:`.

Wired into:
  - FeedCommand.resolveFollowing — replaces a kind:3 drain with
    ctx.contactsOf(self).
  - DmCommands.resolveDmRelays — `amy dm send` / `dm list`.
  - KeyPackageCommands.check.
  - AwaitCommands (key-package / member / admin polling loops).
  - GroupAddMemberCommand (one cache hit per invitee).

All five sites now go: try cache → if miss, run the existing
RecipientRelayFetcher / drain. The drain itself populates the cache
via verifyAndStore, so subsequent runs are local-only. Replaceable
slot shortcut means each cached read is one stat + one readString,
not a directory walk.

README "Local event store" section gains a list of which commands
are cache-first today.
2026-04-25 03:34:00 +00:00
Claude bf1c4c23cb feat(cli): amy profile show is now cache-first
The first command to consume the local event store as a read source.
Default behaviour: read from the FsEventStore via Context.profileOf().
Pass --refresh to skip the cache and drain from relays (which then
re-populates the store as a side effect, like every other drain).

Output JSON gains a "source" field so scripts/agents can tell whether
the result came from disk or the network:

  source = "cache"  → served from <data-dir>/events-store/, queried_relays = []
  source = "relays" → fresh relay drain, queried_relays = list

Cache hit goes through the planner's slot shortcut from the previous
commit: one stat + one read on replaceable/0/<pubkey>.json, no
directory scan.

profile show + profile edit now have entries in the verb table in
cli/README.md.
2026-04-25 03:25:11 +00:00
Claude 22a418bea2 feat(cli): make FsEventStore the source of truth for relay events
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.
2026-04-25 01:40:05 +00:00
Claude ea64f7e06d feat(cli): wire FsEventStore into amy Context (step 9)
- 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.
2026-04-25 00:47:48 +00:00
Claude b507cb5986 feat(cli): move private key out of identity.json into keychain / NIP-49
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
2026-04-24 21:06:18 +00:00
Claude b77e6aa54f feat(cli): restrict on-disk data to owner-only permissions
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
2026-04-24 20:30:55 +00:00
Claude 8cbe8c67fe refactor(cli): rename default data-dir to ./amy and nest MLS state under marmot/
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.
2026-04-24 19:15:58 +00:00
Claude 825e2fd911 refactor(cli): group post + feed under notes subcommand
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.
2026-04-24 18:28:52 +00:00
Claude 525c4bce0f feat(cli): add profile, post, and feed verbs
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.
2026-04-24 18:01:12 +00:00
davotoula 999184cced optimise imports 2026-04-24 18:15:58 +02:00
davotoulaandClaude Opus 4.7 20367af3db fix(cli): warn when FileStores delete() fails
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>
2026-04-24 11:10:39 +02:00
Claude 655e56791b Merge branch 'main' into claude/review-marmot-implementation-BSrzC
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt
2026-04-23 22:36:29 +00:00
Claude 310cae0a64 feat(cli): add amy marmot message react / delete verbs
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
2026-04-23 20:50:47 +00:00
Claude e37899a115 feat(cli): add amy marmot reset safety-valve verb
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
2026-04-23 20:29:01 +00:00
Claude c614400b23 feat(cli): amy dm send-file --file PATH (encrypt + upload + publish)
`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 ...)`.
2026-04-23 19:51:06 +00:00
Claude 305ce20b52 feat(cli): NIP-17 file DMs (kind:15) on receive + send, strict 10050
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.
2026-04-23 19:34:52 +00:00
Claude 1e5b5d6276 refactor(commons): share NIP-59 gift-wrap+seal unwrap helper
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.
2026-04-23 18:40:35 +00:00
Claude 15205c24b5 feat(cli): amy dm send|list|await (NIP-17)
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.
2026-04-23 18:23:12 +00:00
Claude 0657f6e1c7 fix(cli): apply Marmot relay-routing rules across every marmot command
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.
2026-04-22 22:15:10 +00:00
Claude 5289fa3398 style(cli): import NormalizedRelayUrl + Amethyst defaults instead of inlining FQNs 2026-04-22 22:04:30 +00:00
Claude f6f2a34353 fix(cli): route Marmot welcome + KeyPackage fetch to the invitee's relays
`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.
2026-04-22 21:45:14 +00:00
Claude 94c5e0e833 chore(cli): surface ingest Failure message in harness log
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
2026-04-22 03:40:14 +00:00
Claude f08b010f50 fix(marmot,cli,interop): interop-compatible Marmot flows and harness correctness
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
2026-04-22 00:28:45 +00:00
Claude 020abccf57 fix(cli): mark Identity.hasPrivateKey @JsonIgnore
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
2026-04-21 22:30:08 +00:00
Claude 559c69660f feat(cli,commons): amy login + amy create, share defaults with Amethyst
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
2026-04-21 21:41:47 +00:00
Claude 89198ab24e refactor(marmot,cli): lift shared logic into quartz/commons
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
2026-04-21 20:43:00 +00:00
Claude 3fa45a47c6 feat(cli): add amy CLI with Marmot subcommand surface
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
2026-04-21 20:09:11 +00:00