Skip to content

feat: portal connector backend (run chx as a portal client) - #14

Open
antra-tess wants to merge 11 commits into
mainfrom
feature/portal-connector
Open

feat: portal connector backend (run chx as a portal client)#14
antra-tess wants to merge 11 commits into
mainfrom
feature/portal-connector

Conversation

@antra-tess

Copy link
Copy Markdown
Owner

Summary

Adds an optional second connector backend so a Chapter3 bot can route through the shared portal-relay (via @animalabs/portal-client over a websocket) instead of holding its own discord.js gateway connection — letting many agents share one bot slot. The backend is selectable per-bot via config.yaml (connector_backend: portal); the default remains the bot's own discord.js gateway, fully unchanged.

Live in production: a GLM-5.2 bot (glm52) is running on this backend through the prod relay.

What's in it

  • IConnector interface (src/connector/types.ts) — the exact surface the agent loop / API / sleep logic depend on. DiscordConnector implements it with zero behavioral changes (a discord.js Message structurally satisfies the two narrowed signatures).
  • PortalConnector (src/connector/portal/) wrapping PortalClient:
    • inbound adapters: PortalMessage/PortalEvent → the discord.js-Message-shaped objects the loop reads off event.data (incl. persona-role-mention → "bot mentioned" translation).
    • .history engine ported to PortalMessage (identity = relay id, ordering/boundaries = nativeId snowflake), reusing the pure .history parsers.
    • full fetchContext (history → thread-parent → cache-anchor → attachments) + a standalone AttachmentCache.
    • pins (.config/.steer/.sleep via list_pins + pins_update) and role-name auth (list_roles + list_members).
    • real attachment uploads via fileFromBytes; outbound mention normalization.
  • Config-driven portal settings read from the bot's config.yaml (connector_backend, portal_url, portal_invite, optional portal_creds_path); env vars override. Enroll-once with a persistent creds path (defaults to the EMS bot dir) so the persona survives restarts/redeploys. Channel access is role/permission-based (no subscriptions).

Dependencies

  • @animalabs/portal-client / @animalabs/portal-protocol ^0.1.0 (published)
  • @animalabs/membrane ^0.5.67 (OpenRouter reasoning-trace capture + round-trip)

Testing

  • 339 unit tests pass (305 existing + 34 new: adapters, .history engine, pin classification).
  • Live-validated against the production relay: enroll-once, connect, fetch_history/.history, list_pins/list_members/list_roles, send/edit/react, fileFromBytes upload, inbound delivery, and an end-to-end GLM-5.2 conversation.

Notes / known degradations (documented in code)

  • Personas can't add native Discord reactions (webhooks can't); reaction feedback maps to portal pseudo-reactions.
  • pinMessage is a no-op (portal has no pin-mutation RPC).
  • Inbound context uses portal's cleanContent (@name) rather than <@name> brackets.
  • For reasoning models over OpenRouter, set streaming: false in the bot config (provider streaming mangles reasoning/content); non-streaming is clean.

Related (separate, not in this PR)

  • A relay-side moderation-delete path (so m @bot can delete the user's trigger) lives in anima-research/portal and needs its own commit/PR + the bot granted Manage Messages.

🤖 Generated with Claude Code

Add a second connector backend so the bot can route through the shared
portal-relay (via @animalabs/portal-client over WS) instead of holding its
own discord.js gateway. Selectable per-bot through config.yaml
`connector_backend: portal` (env vars still override).

- IConnector interface; DiscordConnector + new PortalConnector implementations
- portal .history engine, fetchContext, attachment cache, pins, role auth,
  inbound event adapters (34 unit tests)
- config-driven portal settings (connector_backend/portal_url/portal_invite)
  read from the bot config.yaml; enroll-once with a persistent creds path so
  the persona survives restarts/redeploys; channel access is role-based
- deps: @animalabs/portal-client/protocol ^0.1.0, membrane ^0.5.67
  (OpenRouter reasoning-trace capture + round-trip)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds an optional PortalConnector backend so a Chapter3 bot can route through a shared portal-relay websocket instead of holding its own discord.js gateway connection. The backend is selected per-bot via connector_backend: portal in config.yaml, with the discord.js path fully unchanged.

  • IConnector interface (src/connector/types.ts) extracted from DiscordConnector, which now implements it; AgentLoop and ApiServer both depend only on the interface.
  • PortalConnector (src/connector/portal/) implements the full surface: inbound event adapters, a .history engine ported to PortalMessage, fetchContext with attachment caching, pin management via list_pins/pins_update, and role-name auth via list_members/list_roles.
  • Config-driven bootstrap in main.ts handles enroll-once portal credentials with a persistent creds path.

Confidence Score: 3/5

The portal connector is largely solid and tested, but sendMessage passes the reply reference to every content chunk rather than only the first, meaning any multi-chunk bot response creates multiple simultaneous replies to the same original message instead of a clean sequential thread.

The reply-to-all-chunks bug is a real behavioral divergence from the discord backend: the discord connector explicitly guards the reply reference with if (i === 0 && replyToMessageId), while the portal connector iterates all chunks with replyToId: replyToMessageId unchanged. Long responses in the portal backend will produce a reply pile rather than a thread, which is visually broken and reproducible once any bot response exceeds 1800 characters.

src/connector/portal/portal-connector.ts sendMessage (reply-to-all-chunks); src/connector/portal/adapters.ts reaction_add branch (userId can be undefined).

Important Files Changed

Filename Overview
src/connector/portal/portal-connector.ts Core PortalConnector implementation; sendMessage passes replyToId to every content chunk instead of only the first, diverging from the discord backend and creating a reply-pile for long responses.
src/connector/portal/adapters.ts Pure shape-translation layer; well-tested. reaction_add case can produce userId: undefined when the by array is empty, which leaks into the agent loop's bot-identity filter.
src/connector/types.ts New IConnector interface and shared types; cleanly extracted from discord/connector.ts with back-compat re-exports.
src/connector/portal/context.ts Portal context assembly mirroring the discord path; snowflake ordering, anchor extension, and attachment handling all look correct.
src/connector/portal/history.ts Portal .history engine ported from discord context-fetch; uses nativeId snowflakes for ordering, relay id for identity — architecture is sound and well-tested.
src/connector/util/attachment-cache.ts Backend-agnostic image/text attachment cache; correctly detects types via magic bytes and caches to disk with a url-map. Defensive error handling throughout.
src/main.ts Config-driven connector selection; portal path handles enroll-once creds correctly. Error message for missing identity is incorrect for the portal backend.
src/discord/connector.ts Migrated to implement IConnector; types moved to connector/types.ts with re-export for back-compat — no behavioral changes.
src/api/server.ts Switched from DiscordConnector to IConnector; added guards for portal mode where Discord-specific guild/user APIs are unavailable.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[main.ts: connector bootstrap] -->|connector_backend == portal| B[PortalConnector]
    A -->|connector_backend == discord / default| C[DiscordConnector]
    B --> D[IConnector interface]
    C --> D
    D --> E[AgentLoop]
    D --> F[ApiServer]

    B --> G[PortalClient websocket]
    G -->|PortalEvent| H[queueEventFromPortal adapters.ts]
    H -->|Event| I[EventQueue]
    I --> E

    B --> J[buildPortalContext context.ts]
    J --> K[fetchPortalHistory history.ts]
    J --> L[AttachmentCache util/attachment-cache.ts]

    B --> M[Pin cache bootstrapPins / ensurePins]
    M --> N[extractConfigs / extractSteers / extractSleeps util/pin-extract.ts]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[main.ts: connector bootstrap] -->|connector_backend == portal| B[PortalConnector]
    A -->|connector_backend == discord / default| C[DiscordConnector]
    B --> D[IConnector interface]
    C --> D
    D --> E[AgentLoop]
    D --> F[ApiServer]

    B --> G[PortalClient websocket]
    G -->|PortalEvent| H[queueEventFromPortal adapters.ts]
    H -->|Event| I[EventQueue]
    I --> E

    B --> J[buildPortalContext context.ts]
    J --> K[fetchPortalHistory history.ts]
    J --> L[AttachmentCache util/attachment-cache.ts]

    B --> M[Pin cache bootstrapPins / ensurePins]
    M --> N[extractConfigs / extractSteers / extractSleeps util/pin-extract.ts]
Loading

Comments Outside Diff (2)

  1. src/connector/portal/portal-connector.ts, line 1204-1212 (link)

    P1 Multi-chunk reply: all chunks get replyToId, not just the first

    The discord connector explicitly only applies the reply reference to the first chunk (if (i === 0 && replyToMessageId)). Here, replyToId: replyToMessageId is forwarded on every loop iteration, so a split response sends multiple messages all replying to the same original, which creates a reply-pile instead of a sequential thread. This diverges from the Discord backend behavior users and the relay likely expect.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/connector/portal/portal-connector.ts
    Line: 1204-1212
    
    Comment:
    **Multi-chunk reply: all chunks get `replyToId`, not just the first**
    
    The discord connector explicitly only applies the reply reference to the first chunk (`if (i === 0 && replyToMessageId)`). Here, `replyToId: replyToMessageId` is forwarded on every loop iteration, so a split response sends multiple messages all replying to the same original, which creates a reply-pile instead of a sequential thread. This diverges from the Discord backend behavior users and the relay likely expect.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/main.ts, line 178-179 (link)

    P2 The error message hard-codes "Discord" even when the portal backend is active. If the portal connector returns no persona ID (e.g. on a failed enroll), the operator sees a misleading error pointing at Discord instead of the relay.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/main.ts
    Line: 178-179
    
    Comment:
    The error message hard-codes "Discord" even when the portal backend is active. If the portal connector returns no persona ID (e.g. on a failed enroll), the operator sees a misleading error pointing at Discord instead of the relay.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/connector/portal/portal-connector.ts:1204-1212
**Multi-chunk reply: all chunks get `replyToId`, not just the first**

The discord connector explicitly only applies the reply reference to the first chunk (`if (i === 0 && replyToMessageId)`). Here, `replyToId: replyToMessageId` is forwarded on every loop iteration, so a split response sends multiple messages all replying to the same original, which creates a reply-pile instead of a sequential thread. This diverges from the Discord backend behavior users and the relay likely expect.

### Issue 2 of 3
src/connector/portal/adapters.ts:115-120
`e.reaction.by[0]?.id` is `undefined` when the `by` array is empty. A reaction with no known reactor then has `userId: undefined`, which silently passes the "skip bot's own reaction" check in the agent loop (`reaction.userId === this.botUserId` is `false` for `undefined`) and is logged with `userId: undefined`. Guard with a fallback to avoid ambiguity.

```suggestion
        data: {
          messageId: e.messageId,
          emoji: e.reaction.emoji,
          userId: e.reaction.by[0]?.id ?? 'unknown',
          messageAuthorId: ctx.authorOf(e.messageId),
        },
```

### Issue 3 of 3
src/main.ts:178-179
The error message hard-codes "Discord" even when the portal backend is active. If the portal connector returns no persona ID (e.g. on a failed enroll), the operator sees a misleading error pointing at Discord instead of the relay.

```suggestion
    if (!botUserId || !botUsername) {
      throw new Error(`Failed to get bot identity from ${backend === 'portal' ? 'portal relay' : 'Discord'}`)
```

Reviews (1): Last reviewed commit: "feat: portal connector backend (run chx ..." | Re-trigger Greptile

Comment on lines +115 to +120
data: {
messageId: e.messageId,
emoji: e.reaction.emoji,
userId: e.reaction.by[0]?.id,
messageAuthorId: ctx.authorOf(e.messageId),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 e.reaction.by[0]?.id is undefined when the by array is empty. A reaction with no known reactor then has userId: undefined, which silently passes the "skip bot's own reaction" check in the agent loop (reaction.userId === this.botUserId is false for undefined) and is logged with userId: undefined. Guard with a fallback to avoid ambiguity.

Suggested change
data: {
messageId: e.messageId,
emoji: e.reaction.emoji,
userId: e.reaction.by[0]?.id,
messageAuthorId: ctx.authorOf(e.messageId),
},
data: {
messageId: e.messageId,
emoji: e.reaction.emoji,
userId: e.reaction.by[0]?.id ?? 'unknown',
messageAuthorId: ctx.authorOf(e.messageId),
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/connector/portal/adapters.ts
Line: 115-120

Comment:
`e.reaction.by[0]?.id` is `undefined` when the `by` array is empty. A reaction with no known reactor then has `userId: undefined`, which silently passes the "skip bot's own reaction" check in the agent loop (`reaction.userId === this.botUserId` is `false` for `undefined`) and is logged with `userId: undefined`. Guard with a fallback to avoid ambiguity.

```suggestion
        data: {
          messageId: e.messageId,
          emoji: e.reaction.emoji,
          userId: e.reaction.by[0]?.id ?? 'unknown',
          messageAuthorId: ctx.authorOf(e.messageId),
        },
```

How can I resolve this? If you propose a fix, please make it concise.

LuxiaSL and others added 10 commits June 24, 2026 13:07
Brings the mention-aware pinned-command matcher (.config / .sleep / .steer) onto the portal branch and wires the portal backend: TrackedPin/PinnedSteer carry resolved persona/role mentions; portalMessageToTrackedPin populates them from PortalMessage.mentions; the portal pin-extract resolves .config targets against the persona identity (strip-on-match); PortalConnector.getOwnRoleIds returns null (portal targets via persona mention). IConnector gains getOwnRoleIds and the PinnedSteer steer return type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portal personas have no Discord user id (getBotUserId returns the relay
persona id), so charging them via the Discord-user-keyed botId could never
match a cost row set by an admin picking the portal-<name> role. Add
IConnector.isPortal() and, when true, send the EMS bot name (this.botId) as
the checkAndDeduct botId instead of botUserId. This matches the cost key the
infra admin commands resolve from the portal role, and is uniform with how
Phase 1 /sleep keys portal bots. Account bots stay keyed by Discord user id.

Latent until a portal bot has soma enabled; pairs with the infra cost-command
change keying portal bots by EMS name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions, identity error label

sendMessage: only the first chunk replies to the trigger (matches DiscordConnector at connector.ts:1086). Previously replyToId was forwarded on every chunk, so a split response piled multiple replies on the original message.

reaction_add adapter: drop events with no known reactor (empty reaction.by) instead of emitting userId:undefined — which bypassed the agent loop skip-own-reaction check (userId === botUserId is false for undefined) and let the bot react to its own/unattributable reactions. Adds a test.

main.ts: the identity-failure error now names the active backend ("portal relay" vs "Discord") instead of hard-coding Discord, so a failed portal enroll points at the relay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reply-ping hard rule (1035c11) made replies activate only when the bot lands
in message.mentions. That holds for account bots (Discord adds the replied-user)
but not for portal personas: webhooks can't carry a native reply/ping, so a
reply-ping never appears in mentions — it only shows up as the relay's per-
recipient 'reply' address reason. The adapter was discarding that AddressInfo, so
after 1035c11 portal bots could no longer be woken by any reply.

- Carry AddressInfo (addressedToMe/reasons) from the PortalEvent through the
  adapter onto InboundMessage._address.
- Add a portal-only activation branch: a 'reply' reason wakes the bot, with the
  same bot-reply-chain-depth guard as the mention path. Account/discord.js events
  carry no _address, so the hard rule is unchanged for them.
- Trace-reason labeling recognizes the portal 'reply' reason (authoritative across
  restarts, unlike the in-memory botMessageIds set).

Restores pre-hard-rule reply behavior for portal only. The relay can't encode the
ping toggle for personas, so any reply to one of our messages counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picks up the resilient-reconnect fix (jitter + emit failure-isolation) in
@animalabs/portal-client 0.2.1. Compatibility verified: connector typechecks and
portal tests pass against 0.2.x; 0.1→0.2 protocol changes are additive and the
0.1.0 clients already interoperate with the 0.2.x relay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay addresses personas via pooled Discord roles named portal-<name>, so a
resolved role mention in cleanContent reads "@portal-glm52" — addressing plumbing
that's noise to the model. Strip the prefix in portalMessageToDiscordMessage so the
model sees "@GLM52". Text-only rewrite of the readable rendering; a no-op on raw
<@&id> content and on ordinary @mentions.

Portal-specific (the prefix only exists in portal contexts), so it lives on the
portal branch — main account bots never see these roles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portal persona enrollment previously always used BOT_NAME (the EMS
directory name) as the desiredName, forcing display identity to match
the bot's slug. Add config + env override so a persona whose display
name differs from its slug (e.g., "Claude Sonnet 5 CHX" for
BOT_NAME=sonnet5) can be enrolled with the intended name.

Precedence: PORTAL_DESIRED_NAME env > portal_desired_name config >
BOT_NAME. Only consulted at first enrollment; existing personas keep
whatever name they were originally enrolled with.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants