The allowance was derived from the adaptive trust level with no way to
change it, which made the dashboard's "1 / 2 today" unexplainable and
left no way to slow an account down or push one along.
`dailyLimit` is null by default, keeping today's adaptive behaviour. A
whole number between 1 and 50 overrides it and is enforced verbatim in
either direction — it can raise an account above what it has earned or
hold it below. The trust level keeps being tracked underneath and is
reported as `adaptiveAllowance`, so the earned figure stays visible next
to the override, and clearing the field hands pacing back to the ladder.
Raising the limit above the earned allowance does remove the adaptive
backoff after a bad comment, so the dashboard says exactly that where the
limit is set. It does not touch the pause on two consecutive removals, a
comment the public cannot see, or a captcha challenge: those are safety
stops rather than rate controls and no limit setting can disable them.
Also correct two things the page was saying badly. The allowance is now
labelled "used in last 24h" with the trust level shown as what it earns,
because it is a rolling window and not a midnight reset. And a
per-community cap above the account allowance now states how much of it
is actually reachable — "1 of 10 today" beside an account limit of 2 read
as far more headroom than existed, since caps distribute the allowance
rather than adding to it.
Verified against a local instance: override above and below the earned
allowance, unrelated saves preserving it, clearing it through the UI,
rejection of 0/51/2.5/non-numeric, and a limit failing to clear a pause.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new Reddit account cannot start chats until Reddit considers it
established, and nothing in the app previously did anything about that.
Account growth posts a small number of AI-written comments per day on
active threads in an operator-chosen list of communities, per account,
so the history that lifts the hold actually accumulates.
Growth is account-scoped rather than part of the shared outreach funnel:
karma, comment history and pacing all belong to one identity. It runs as
the lowest-priority work in the account runtime, after inbound replies,
and deliberately keeps running while an account is held out of outbound
messaging.
Comments publish without operator review, so the guards are the feature:
- Both screening stages fail closed. A deterministic gate rejects links,
contact solicitation, user tags, filler and any text that reveals the
generator; the classifier must actively approve with confidence, and
an outage stalls growth rather than publishing unreviewed text.
- Thread text reaches the model only as fenced untrusted data, since
Reddit posts do contain instructions aimed at automated readers.
- The daily allowance is adaptive, not configured: it starts at two per
day and rises only while comments survive and earn upvotes.
- Posted comments are swept at ~15m/2h/24h with one batched info.json
request, plus a single signed-out probe that detects a comment the
public cannot see. Two consecutive removals, an invisible comment, or
a captcha challenge pauses the account until an operator clears it. A
failed probe never pauses anything.
- One comment per thread across every owned account, enforced by a
unique index, because two of our accounts on one post is the easiest
pattern to correlate.
- A comment interrupted mid-post is abandoned, never retried: we cannot
know whether the write landed and a duplicate cannot be withdrawn.
- Reddit's refusals are authoritative — a ban disables that community
for that account, a throttle backs off at the scope Reddit named, and
a signed-out response invalidates the session.
Comments are submitted through the account's own browser session against
the old.reddit form endpoint, which returns machine-readable error codes
and needs no credentials beyond the cookies already imported.
API-first: /api/growth exposes state, settings, the community list and
pause/resume, all verified against a local instance. Route responses are
parsed with the shared contracts in tests so the server-side mirror
cannot drift from what the dashboard expects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.
Replace the boolean with an explicit three-way mode:
running — discover, screen, send, automate replies
scan — refresh chats, detect replies, check responses; send nothing
and discover nothing; actionable replies surface as backlog
stopped — everything halts, including background media sync
scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.
The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New accounts cannot start chats until Reddit considers them established.
Until now the bot would burn a candidate on every invitation Reddit was
always going to reject, with nothing in the dashboard explaining why the
account looked idle.
Two signals feed the verdict. A periodic profile check reads the
account's own age, karma and email verification from about.json and
compares them against configurable thresholds — that is what flags a
brand new account before it wastes anything. A rejected invitation is
authoritative: Reddit has told us directly, so the account is held out of
sending with a backoff that doubles per rejection, and a delivered
invitation clears the flag outright.
An unestablished account takes no new posts (canAcceptPost), raises an
attention item, reports an `unestablished` sending state, and shows its
age/karma evidence on the Accounts page. An unchecked account is always
allowed through, so a first run is never blocked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Missing conversations: recipient Reddit accounts that are deleted or suspended
left conversations unaccounted for. A new RecipientAccountStatusService polls
Reddit for the users behind cached conversations and records their account
status durably, with progressive backfill and delta-media fixes in ChatService
so history and images are no longer dropped mid-sync.
Media: attachMedia now surfaces download failures as MediaAttachmentError so the
sync cursor can be retried instead of silently losing an image, recognizes image
bytes when Matrix returns a generic content type, and keeps separate gallery
occurrences when the same asset appears in more than one event.
Manual inbox tab: the conversations list now carries the reply-loop status, so
the inbox can separate conversations the operator is driving by hand from the
ones automation is still handling. A Manual filter pill and a per-row chip
explain why each conversation was handed over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conversations were hard to manage because "unread" came straight from
Matrix's notification_count, and nothing in the app ever sends a read
receipt — so opening a conversation in the dashboard could never clear it
and the unread set only ever grew. The list also collapsed three
orthogonal facts into one word, labelling "they spoke last and we have
not replied" as "Up to date".
Read state is now ours:
- conversation_read_state table (migration 19) plus
ConversationReadStateRepository, kept separate from conversation_states
so a read write can never be lost to mergeConversationState. The
watermark is monotonic, so a stale poll cannot resurrect an unread
badge.
- hasUnreadInbound and needsReply are derived server-side and returned on
every conversation. Matrix's `unread` stays on the contract but the
dashboard no longer reads it.
- POST /conversations/{id}/read|unread|status and
POST /conversations/bulk-read, with optimistic cache updates.
Inbox rework:
- List rows are a real grid (checkbox, open button, chip strip) instead of
a full-bleed absolute overlay button, which previously forced a
pointer-events escape hatch for anything interactive in a row. Rows use
aria-current rather than aria-pressed. Not a listbox: options must not
contain interactive descendants.
- Counted filter pills replace the select, independent state chips replace
the collapsed status word, plus a sort control (including oldest
unanswered), a per-account filter, age-coloured timestamps, and an
explicit count of rows hidden by the seller filter.
- Keyboard: j/k/arrows, Enter, u, e, / and Escape. Shortcuts yield to any
open dialog so Escape dismisses the modal, not the conversation behind
it. Multi-select with bulk mark-read and mark-done.
- Detail panel: auto-mark-read on open, a "New messages" divider pinned at
mount (deriving it from the live watermark made it vanish a second after
opening), triage actions in the header, the automation banner moved out
of the composer, day dividers with time-only stamps, and a flex column
so history scrolls in its own region instead of painting over the
composer.
- Row order freezes while a conversation is open, with a "N new — refresh"
pill, so a background poll cannot reorder rows mid-triage.
Two database tests rewound user_version by an offset from the latest
version, which silently re-targets a different migration whenever one is
appended. They now pin absolute versions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Expired Matrix bearers no longer pause an account until a new extension
dump is uploaded. BrowserMatrixTokenRefresher loads chat.reddit.com in
the account's persistent browser profile (whose Reddit cookies stay
valid), lifts the fresh bearer off the first authenticated Matrix
request, verifies it via whoami, swaps it into the live MatrixClient
and persists it to the account row.
Wired into the per-account runtime at boot verification, the auth-gate
authenticator, and ChatService's 401 retry path. A dump is now only
needed when the Reddit cookies themselves no longer authenticate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a durable mapping so the UI never has to show raw t2_xxx IDs or
MXIDs just because the in-memory cache is cold after a restart.
ChatService seeds from the table on construction and writes through
every resolution path; cached chats whose username is still t2_xxx
get upgraded from the DB on loadCache.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Threads MessagesRepository through ChatService so first outgoing chat
messages are persisted for stats, and extends the repo + DB client to
support chat-room-keyed lookups. Adds tests covering the new behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
Add gitignore rules for browser session data, SQLite databases,
media downloads, and build artifacts. Remove previously tracked
runtime files from the repository.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>