Commit Graph
62 Commits
Author SHA1 Message Date
Jamie Brunton 4798385d8b Add manual outreach funnel clearing 2026-08-14 09:02:18 +01:00
Jamie Brunton e9a4c965ee Add commenter targeting mode 2026-08-14 08:51:27 +01:00
Jamie BruntonandClaude Opus 5 3f60f7f665 Link learning trajectories to their conversation
The trajectory detail panel showed the conversation id as plain text, so
reviewing an example meant copying the room id and hunting for it in the
inbox — with the evidence panel open on one screen and the conversation on
another.

Render it as a link instead. The id has to travel as an owned id
(accountId~conversationId): once account runtimes exist, findConversation
rejects a bare room id outright, so a plain link would 404 for every
multi-account install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:04 +01:00
Jamie BruntonandClaude Opus 5 142378df33 feat: show the conversation evidence behind each learning trajectory
Reviewers were asked to include or exclude learning trajectories while
only seeing metadata — credit class, confidence, status. The decisions
and their redacted context turns were already captured and stored, but
no endpoint returned them, so the evidence was unreachable from the UI.

Add GET /api/learning/trajectories/:id and an expandable row on the
learning page that fetches on demand.

Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit
and imageOutcomeAt. The learning loop added these to SentMessageRecord
and they flow out of /api/messages, but the strict contract rejected
them, breaking the dashboard.

Enable generation with shadow mode off so eligible examples reach reply
prompts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:23:22 +01:00
jjbrunton bc42c55555 Remove browsing selection ceilings 2026-08-11 22:54:48 +01:00
jjbrunton a71ad56f3c Expose browsing daily target range 2026-08-11 22:22:09 +01:00
jjbrunton e890f74478 Configure browsing session pacing and size 2026-08-11 22:17:18 +01:00
jjbrunton 540590f43e Run immediate browsing sessions with enabled votes 2026-08-11 21:59:13 +01:00
jjbrunton 7bf1aa9d24 Enable automatic browsing upvotes 2026-08-11 21:52:54 +01:00
jjbrunton 27012399ec Add account browsing simulation 2026-08-11 21:40:31 +01:00
Jamie BruntonandClaude Opus 5 5deac87cf1 feat: let the operator set the daily comment limit per account
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>
2026-08-11 21:07:25 +01:00
Jamie BruntonandClaude Opus 5 f88db1ae7d fix: stop Reddit's block page reading as a signed-out session
Reddit answers a throttled client with a large HTML 403 interstitial. To
the comment client that was indistinguishable from a logged-out session:
the body would not parse, so no modhash was found, so it reported
"Reddit did not return a modhash for this session" and returned
session-invalid — which calls auth.invalidate() and so could disrupt
outreach and chat for an account whose session was perfectly healthy.

That matches what prod is doing. u/Saira_Makimad posted successfully 25
seconds after boot and every later attempt failed with that message,
which is the shape of Reddit starting to throttle rather than a session
going bad.

A response is now only treated as a verdict about the session when it is
actually JSON. Non-JSON, at any status, is Reddit refusing to serve us
and stays retryable. A genuine JSON 403, or JSON with no modhash, still
invalidates as before.

When Reddit does report a signed-out session, the log and the recorded
reason now name the cookies visible to the write origin — names and
domains only, never values. Cookies scoped to www.reddit.com rather than
.reddit.com would produce exactly this symptom, and this makes that
answerable from the logs instead of by guesswork.

Also clarify the allowance in the dashboard. It is derived from the trust
level rather than typed in, and it is a rolling 24-hour window, neither
of which the page said. A per-community cap above the account allowance
now says how much of it is actually reachable, because "1 of 10 today"
next to an account limit of 2 reads as far more headroom than exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:59:25 +01:00
Jamie BruntonandClaude Opus 5 1ac6ad22ad feat: grow new accounts by commenting on active threads
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>
2026-08-11 20:08:52 +01:00
Jamie BruntonandClaude Opus 5 14d92e14ee feat: add scan mode so conversations stay in sync without sending
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>
2026-08-11 10:16:24 +01:00
Jamie BruntonandClaude Opus 5 3a40651930 fix: never let a guessed threshold hold an account out of sending
The profile check was allowed to set state 'unestablished', which made
isSendable() false, which made canAcceptPost() false — so a flagged
account never attempted an invitation and the functional probe could
never run. The one signal with real authority was gated behind the one
that is guessing, and because each check pushed retryAt forward by
another interval, accounts that send perfectly well were held
indefinitely.

Reddit does not publish the thresholds its chat gate applies, so ours are
a guess and are now advisory only. Sending is held solely by a live
rejection, tracked as sendingHeld on the account summary and keyed off by
the attention feed, the sending state and the Accounts page.

Reason strings now say "below the configured floor of N" rather than
"needs N", and the Accounts page distinguishes "Sending on hold" from
"Below thresholds", stating outright that the latter is our own number
and the account is still sending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:49:40 +01:00
Jamie BruntonandClaude Opus 5 89eb4583f8 feat: flag accounts Reddit considers unestablished and re-test them
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>
2026-08-11 09:14:47 +01:00
Jamie BruntonandClaude Opus 5 c43e534cf1 refactor(dashboard): rethink settings, split out subreddits and accounts
The settings page had accumulated five tabs whose names no longer matched
their contents, three different save models, and the whole subreddit
configuration buried under a tab named after a checkbox.

Structure:
- Subreddits becomes its own page: a list showing each community's mode,
  tone, computed share of polling, and live sent/reply stats, with one
  subreddit edited at a time in a drawer.
- Accounts becomes its own page. It is session lifecycle, not config.
- Settings collapses to one scrolling page: Safety, Pacing (Monitoring and
  Rate limits merged), Targeting, Notifications, Advanced.
- The sidebar is grouped, having gone from 9 to 11 destinations.

Saving:
- Every section owns its own draft, validation, risk gate and save via
  useConfigCard, submitting only the keys it owns. A syntax error in the
  advanced JSON can no longer block a rate-limit change, and enabling an
  autonomous policy is its own deliberate commit.
- UnsavedChangesProvider restores the leave-page guard across cards that no
  longer share a draft.

Subreddit editing:
- Errors are keyed by field name instead of array index, removing the
  defensive invalidation that existed because row indices shifted on add
  and remove. Confirming an autonomous mode can no longer be attributed to
  the wrong row.
- Weight is shown as the share it actually produces, live while editing.
- Bulk actions cover start/pause polling, tone, policy and removal, because
  production runs 30-40 subreddits and per-row editing does not scale.
  Bulk policy changes name every subreddit turning autonomous, and
  normalise the hybrid mode's turn budget, which has no form to catch it.

Physique targeting moves into its own Targeting card. It sends only
reddit.physiqueTargeting, which the config route applies independently, so
it cannot clobber the pacing card's fields. Turning screening on now warns
that it will reduce how many posts qualify.

Also surfaces the effective sending rate, since the hourly limit and the
minimum delay both throttle and only the binding one matters.

Legacy ?section= links redirect or resolve to anchors.

Fixes found while verifying in a browser: a radio-card rule was shrinking
nested number inputs; .share-preview had no styles; the throughput readout
reported a rate from a blank field; and the reconcile logic warned about a
server change whenever a sibling card saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:07:31 +01:00
jjbrunton 5c57ed337b fix: keep outreach funnel response rollout-compatible 2026-08-10 18:38:39 +01:00
jjbrunton 1515b3e291 fix: enforce current physique range in funnel 2026-08-10 18:35:22 +01:00
jjbrunton d0a8bd0e2f feat: expand outreach targeting and messaging 2026-08-10 17:31:05 +01:00
Jamie BruntonandClaude Opus 5 38d175e4a1 feat: push selected events to a phone over ntfy
Adds a Notifications tab to Settings so an operator can pick which events
reach their phone, published to a single self-hosted ntfy topic.

The notifier subscribes to the realtime event hub the dashboard already
consumes, so every notifiable event keeps exactly one publisher. Inbound
media had no event, so `media.received` is added and published from the
existing durable `gotImage` gate in the chat service — one notification
per conversation, never repeated when the room re-syncs. It feeds the
in-app notification bell as well.

- Best-effort delivery: a rejecting or unreachable ntfy server is logged
  and never propagated into the path that produced the event.
- Events buffered before the notifier attaches are skipped, so a restart
  cannot replay a backlog to somebody's phone.
- Settings are applied field by field onto the live config object, so a
  save takes effect without a restart.
- The access token is environment-owned (NTFY_AUTH_TOKEN) like the other
  secrets: stripped before the settings record is written, never returned
  by the API, which reports only `hasAuthToken`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:55:00 +01:00
Jamie BruntonandClaude Opus 5 827eb3a1e0 feat: force a seller scan over active conversations from the inbox
Adds a "Scan for sellers" button to the conversations header, backed by
POST /api/conversations/seller-scan.

The sweep reuses the existing seller detection rather than inventing a
second one: the configured keyword/domain list, the seller-link funnel
patterns, and the aiReplyScreening confidences all decide the verdict, so
a swept conversation lands in the state the live reply loop would have
given it. Being operator-forced, it deliberately ignores filterSellers
and aiReplyScreening.enabled.

AI is the last resort. Conversations are skipped when already flagged or
killed, settled for free by the deterministic rules, or skipped against a
durable sellerScreenedKey watermark when nothing new has arrived since
the last clean sweep. Only the remainder reaches the model, batched eight
at a time through a new screenConversationsForSellers, so the system
prompt and round trip are paid per batch instead of per conversation. A
classifier outage degrades the sweep instead of failing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:42:55 +01:00
Jamie BruntonandClaude Opus 5 ac12a3146a feat: recover missing conversations, fix media attachment, add manual inbox tab
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>
2026-08-10 11:03:33 +01:00
Jamie BruntonandClaude Opus 5 725b5fc4e6 feat: application-level read state and inbox triage for conversations
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>
2026-08-10 10:06:03 +01:00
Jamie BruntonandClaude Opus 4.8 c8689af04d feat: decouple manual image sharing from automation gating
The "Share a saved image" panel was wired to the automated exchange
engine, so an operator could only share an image right after an inbound
image request, limited to assets the auto-exchanger would itself pick.

Add a manual path that offers any enabled, unused library image at any
point (no request detection, tier, seller, or boundary gating) and sends
it with no reciprocal "your turn" text. Safety flags now surface as a
non-blocking warning instead of disabling the panel. The automated
auto-send path keeps all its guardrails; the shared image-delivery step
is extracted into a helper used by both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 09:18:37 +01:00
Jamie BruntonandClaude Fable 5 e984bf20ac feat: dashboard UI/UX overhaul from full review
Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

Conversations/attention: auto-scroll with jump-to-latest, per-conversation
draft persistence, suggestion undo, 30s auto-refresh with refresh controls,
unread emphasis and counts, blur-consistent share dialog, empty-accounts
guidance, collapsed user lookup, relative timestamps, danger pills for
critical items

Media/library/outreach: click-blurred-to-reveal, lightbox with arrow
navigation and captions, dirty-form preservation across refetches, upload
validation with thumbnail preview, save feedback, result counts with
load-more and clear-filters, ticking countdowns from a single clock

Overview/performance/operations/settings: mutation errors inside the
confirm dialog, live sending-window countdown, offline-aware loading
states, per-panel loading/error with retry, 7/30/90-day range toggle,
console scroll yielding with shown/retained counts, risky-save matching
by subreddit name instead of index, useBlocker unsaved-changes guard,
single-pass validation with focus-to-first-error, access panel JSON
validation and input reset

Tests: 806 -> 844 passing; both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:07:42 +01:00
jjbrunton b573b88b08 feat: add conversation image previews and sharing 2026-08-09 22:28:41 +01:00
jjbrunton c0cc661a2f Fix false autonomous safety handoffs 2026-08-09 21:27:53 +01:00
jjbrunton bf474dfe10 Add consent-gated media exchange library 2026-08-09 19:24:38 +01:00
jjbrunton e9083281a8 feat: configure ready outreach funnel limit 2026-08-09 16:13:58 +01:00
jjbrunton 22eb32e5bf Show per-account delivery status 2026-08-09 16:12:05 +01:00
jjbrunton f55ae7ddf8 Add active outreach funnel dashboard 2026-08-09 15:54:05 +01:00
jjbrunton 92c54feedc Filter detected sellers from conversations 2026-08-09 14:38:03 +01:00
jjbrunton b73e305cf3 Add hybrid R4R media conversation mode 2026-08-09 11:45:45 +01:00
jjbrunton 0f76bcf59a Add per-subreddit R4R conversation modes 2026-08-09 11:30:32 +01:00
jjbrunton 50f9f8a6aa Show Reddit post age in conversations 2026-08-09 09:54:53 +01:00
jjbrunton 83b5f1f5b6 Add attention inbox and AI usage observability 2026-08-09 08:52:32 +01:00
jjbrunton ca59ba0f1f Show pending performance groups before maturity 2026-08-09 08:29:22 +01:00
jjbrunton a85b99ceb6 Add notification centre and realtime toasts 2026-08-09 08:08:44 +01:00
jjbrunton e7c29f91f2 Blur conversation images by default 2026-08-09 07:55:51 +01:00
jjbrunton acc610c2bf feat: link media to conversations 2026-08-09 07:32:26 +01:00
jjbrunton 5ad7f1bf77 feat: label AI-generated conversation messages 2026-08-08 23:23:26 +01:00
jjbrunton 3beb8aada2 feat: automate bounded image-sharing conversations 2026-08-08 23:10:52 +01:00
jjbrunton e8b730b592 feat: add agent API and operational analytics 2026-08-08 22:08:14 +01:00
jjbrunton 5a5a30a1d4 Improve messaging and dashboard reliability 2026-08-08 21:09:20 +01:00
jjbrunton 48492d5238 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	src/browser/client.ts
#	src/services/reddit-session-importer.ts
#	src/web/routes/conversations.ts
2026-08-08 20:17:19 +01:00
jjbrunton 34ae532cdd Fix chat visibility and conversation sync 2026-08-08 19:29:40 +01:00
jjbrunton 16a31335d0 feat: support concurrent Reddit accounts 2026-08-08 19:00:38 +01:00
jjbrunton d819a0f1e6 feat: track received image outcomes 2026-08-08 18:53:11 +01:00
jjbrunton 2c100d72b6 feat: persist settings and enrich conversation context 2026-08-08 18:18:28 +01:00