Compare commits

..

134 Commits

Author SHA1 Message Date
ca7b47e683 fix: avoid clobbering sensitive config fields in install/uninstall
openclaw config get returns redacted values for sensitive fields (apiKey,
token, etc). Reading a parent object and writing it back overwrites real
secrets with the __OPENCLAW_REDACTED__ sentinel.

- install step 5: set only the dirigent provider instead of all providers
- uninstall step 2: set only plugins.load.paths instead of entire plugins tree
2026-04-16 14:33:01 +00:00
h z
4cc787ad90 Merge pull request 'refactor' (#22) from refactor into main
Reviewed-on: #22
2026-04-10 07:49:55 +00:00
41a49e10b3 test: add test plan and test-features script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:40:04 +01:00
32dc9a4233 refactor: new design — sidecar services, moderator Gateway client, tool execute API
- Replace standalone no-reply-api Docker service with unified sidecar (services/main.mjs)
  that routes /no-reply/* and /moderator/* and starts/stops with openclaw-gateway
- Add moderator Discord Gateway client (services/moderator/index.mjs) for real-time
  MESSAGE_CREATE push instead of polling; notifies plugin via HTTP callback
- Add plugin HTTP routes (plugin/web/dirigent-api.ts) for moderator → plugin callbacks
  (wake-from-dormant, interrupt tail-match)
- Fix tool registration format: AgentTool requires execute: not handler:; factory form
  for tools needing ctx
- Rename no-reply-process.ts → sidecar-process.ts, startNoReplyApi → startSideCar
- Remove dead config fields from openclaw.plugin.json (humanList, agentList, listMode,
  channelPoliciesFile, endSymbols, waitIdentifier, multiMessage*, bypassUserIds, etc.)
- Rename noReplyPort → sideCarPort
- Remove docker-compose.yml, dev-up/down scripts, package-plugin.mjs, test-no-reply-api.mjs
- Update install.mjs: clean dist before build, copy services/, drop dead config writes
- Update README, Makefile, smoke script for new architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:07:59 +01:00
d8ac9ee0f9 fix: correct dormancy detection — isEmptyTurn last-line + blocked_pending drain
Two bugs that prevented turn-manager dormancy from ever triggering:

1. isEmptyTurn too strict: agents output multi-line text ending with
   "NO_REPLY" on the last line, but the regex ^NO_REPLY$ required the
   entire string to match. Now checks only the last non-empty line.

2. blocked_pending counter inflation: non-speaker suppressions incremented
   the counter but their stale NO_REPLYs were discarded at the
   !isCurrentSpeaker early return without decrementing. Over a full cycle
   the counter inflated by the number of suppressions, causing the agent's
   real empty turn to be misidentified as stale when it finally arrived.
   Fix: at both early-return points in agent_end (!isCurrentSpeaker and
   !isTurnPending), drain blocked_pending when the turn is empty.

Also fixed: pollForTailMatch now uses any-message detection (instead of
tail-fingerprint content matching) with a 30 s timeout, avoiding infinite
polling when agents send concise Discord messages after verbose LLM output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:50:19 +01:00
9e61af4a16 fix: concluded discussions suppress turns and send single auto-reply
Two bugs in concluded discussion channel handling:

1. before_model_resolve did not check rec.discussion.concluded, so it
   still initialized the speaker list and ran turn management. Fixed by
   returning NO_REPLY early for concluded discussions (same as report mode).

2. message_received fired for all agent VM contexts, causing multiple
   "This discussion is closed" auto-replies per incoming message. Fixed
   with process-level dedup keyed on channelId:messageId (same pattern
   as agent_end runId dedup). Also fixed message_id extraction to look
   in metadata.conversation_info.message_id first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 09:02:10 +01:00
27c968fa69 fix: prevent idle reminder from re-waking dormant discussion channel
The moderator bot's own idle reminder message triggered message_received,
which saw senderId != currentSpeaker and called wakeFromDormant, immediately
undoing the dormant state just entered.

Fix: derive the moderator bot's Discord user ID from the token and skip
wake-from-dormant when the sender is the moderator bot itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:17:55 +01:00
c40b756bec fix: cap blocked-pending counter to prevent unbounded drain loops
In busy channels, many messages arrive during a non-speaker turn,
each incrementing the blocked-pending counter. Without a cap the
counter grows faster than it drains, causing the speaker to spin
indefinitely consuming NO_REPLY completions.

Cap at MAX_BLOCKED_PENDING=3 in both incrementBlockedPending and
markTurnStarted (retroactive cap to recover from accumulated debt).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:11:44 +01:00
74e6d61d4d fix: correct channelId extraction and dormant check in hooks
message-received.ts:
- message_received ctx has channelId/accountId/conversationId (not
  sessionKey). Add extraction from ctx.channelId and metadata.to
  ("channel:ID" format) before the conversation_info fallback.

agent-end.ts:
- When tail-match is interrupted, only call wakeFromDormant() if the
  channel is actually dormant. For non-dormant interrupts (e.g. the
  moderator bot's own trigger messages firing message_received on
  other agents), fall through to normal advanceSpeaker() so the turn
  cycle continues correctly instead of re-triggering the same speaker.
- Import isDormant from turn-manager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 06:24:05 +01:00
b9cbb7e895 fix: change control page routes from gateway to plugin auth
auth: "gateway" requires Bearer token in Authorization header,
which browser direct navigation never sends (no session cookies).
auth: "plugin" allows unauthenticated access on loopback, which
is sufficient since gateway is bound to 127.0.0.1 only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 01:15:29 +01:00
b5196e972c feat: rewrite plugin as v2 with globalThis-based turn management
Complete rewrite of the Dirigent plugin turn management system to work
correctly with OpenClaw's VM-context-per-session architecture:

- All turn state stored on globalThis (persists across VM context hot-reloads)
- Hooks registered unconditionally on every api instance; event-level dedup
  (runId Set for agent_end, WeakSet for before_model_resolve) prevents
  double-processing
- Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag
- Shared initializingChannels lock prevents concurrent channel init across VM
  contexts in message_received and before_model_resolve
- New ChannelStore and IdentityRegistry replace old policy/session-state modules
- Added agent_end hook with tail-match polling for Discord delivery confirmation
- Added web control page, padded-cell auto-scan, discussion tool support
- Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service,
  session-state, turn-bootstrap, policy/store, rules, decision-input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:41:25 +01:00
dea345698b feat: add /list-guilds slash command 2026-04-05 19:42:36 +00:00
d8dcd59715 docs: use {baseDir} placeholder in SKILL.md 2026-04-05 19:28:31 +00:00
fd33290266 feat: add /add-guild slash command 2026-04-05 19:24:06 +00:00
9aa85fdbc5 fix: simplify add-guild script using line-based insertion 2026-04-05 18:51:35 +00:00
c9bed19689 feat: add discord-guilds skill with table merge on install 2026-04-05 18:48:10 +00:00
zhi
895cfe3bab fix: align discussion workspace and tool schemas 2026-04-02 11:52:20 +00:00
zhi
7bccb660df fix: wake origin workflow after discussion callback 2026-04-02 08:20:23 +00:00
zhi
29f1f01219 docs: finalize channel mode and shuffle docs 2026-04-02 07:48:25 +00:00
zhi
15f7d211d7 test: cover discussion hook close and idle behavior 2026-04-02 07:19:57 +00:00
zhi
0f38e34bec test: cover discussion tool registration flows 2026-04-02 06:48:29 +00:00
zhi
b11c15d8c8 test: stabilize channel mode and discussion coverage 2026-04-02 06:18:16 +00:00
zhi
4e0a24333e Complete CSM and channel modes implementation
- Add comprehensive tests for shuffle mode functionality
- Add comprehensive tests for multi-message mode functionality
- Add compatibility tests between different channel modes
- Update documentation to reflect completed implementation
- Mark all completed tasks as finished in TASKLIST.md
- Update CHANNEL_MODES_AND_SHUFFLE.md with implementation status and acceptance criteria
2026-04-02 06:08:48 +00:00
zhi
b40838f259 Refine discussion closure messaging 2026-04-02 05:48:00 +00:00
zhi
16daab666b Complete remaining CSM and channel modes tasks
- Confirmed CSM MVP scope and requirements
- Finalized discussion idle reminder and closed channel templates
- Updated all remaining task statuses as completed
- Verified all functionality through tests
2026-04-02 05:33:14 +00:00
zhi
b7b405f416 test: cover discussion callback flows 2026-04-02 05:18:04 +00:00
zhi
7670d41785 Complete A6 and A7 tasks: Implement closed discussion channel protections and update tasklist
- Complete tasks A6.1, A6.2, A6.3: Turn manager discussion mode handling
- Complete tasks A7.1, A7.2, A7.3, A7.5: Hook-level discussion channel protections
- Add closed discussion channel check to message-sent hook to prevent handoffs
- Update TASKLIST.md to mark completed tasks with [x]
2026-04-02 05:04:47 +00:00
zhi
d44204fabf feat: wire channel mode runtime config and docs 2026-04-02 04:48:20 +00:00
zhi
8073c33f2c docs: update TASKLIST.md with completed multi-message and shuffle mode tasks 2026-04-02 04:37:35 +00:00
zhi
bfbe40b3c6 feat: implement multi-message mode and shuffle mode features
- Add multi-message mode with start/end/prompt markers
- Implement turn order shuffling with /turn-shuffling command
- Add channel mode state management
- Update hooks to handle multi-message mode behavior
- Update plugin config with new markers
- Update TASKLIST.md with completed tasks
2026-04-02 04:36:36 +00:00
zhi
684f8f9ee7 Refine discussion moderator messaging flow 2026-04-02 04:18:45 +00:00
zhi
d9bb5c2e21 Fix discussion channel closure handling 2026-04-02 03:49:03 +00:00
zhi
2c870ea2c5 chore(csm): remove cron helper script 2026-04-02 02:56:20 +00:00
zhi
b9933d899a chore(csm): use claimed-task state machine 2026-04-02 02:49:23 +00:00
zhi
62cd2f20cf feat(csm): bootstrap discussion callback flow 2026-04-02 02:35:08 +00:00
h z
9fa71f37bf Merge pull request 'Use api.pluginConfig directly for Dirigent runtime config' (#20) from fix/use-pluginconfig-runtime-config into main
Reviewed-on: #20
2026-04-01 20:32:39 +00:00
81687e548a Preserve existing Dirigent config during install 2026-04-01 20:17:00 +00:00
0a99abc7e3 Use pluginConfig directly for Dirigent runtime config 2026-04-01 20:01:57 +00:00
nav
0a76dae376 docs: reorganize planning and archive files 2026-03-31 12:46:09 +00:00
nav
dbd0fc68c0 docs: add channel modes plan and expand tasklist 2026-03-31 09:16:59 +00:00
nav
4dde4f6efe docs: align CSM terminology with code 2026-03-30 18:24:24 +00:00
nav
3b5ca21f40 docs: add CSM discussion callback design 2026-03-30 18:14:58 +00:00
c2fe859ea5 Merge pull request 'fix/gateway-keywords-no-empty' (#19) from fix/gateway-keywords-no-empty into main
Reviewed-on: #19
2026-03-10 13:56:45 +00:00
nav
4905f37c1a fix: limit no-reply keywords and log 2026-03-10 13:53:32 +00:00
nav
e6b20e9d52 feat: treat NO/empty as gateway no-reply 2026-03-10 13:42:50 +00:00
13d5b95081 Merge pull request 'fix/no-reply-empty-content-detection' (#18) from fix/no-reply-empty-content-detection into main
Reviewed-on: #18
2026-03-09 21:49:59 +00:00
zhi
08f42bfd92 fix: skip rules-based no-reply override when turn manager allows agent
When the turn manager determines it's an agent's turn (checkTurn.allowed),
the rules engine's evaluateDecision() could still override the model to
no-reply with reason 'rule_match_no_end_symbol'. This happened because:

1. The sender of the triggering message (another agent) was not in the
   humanList, so the rules fell through to the end-symbol check.
2. getLastChar() operates on the full prompt (including system content
   like Runtime info), so it never found the end symbol even when the
   actual message ended with one.

Fix: return early from before_model_resolve after the turn check passes,
skipping the rules-based no-reply override entirely. The turn manager is
the authoritative source for multi-agent turn coordination.

Tested: 3-agent counting chain ran successfully (3→11) with correct
NO_REPLY handling when count exceeded threshold.
2026-03-09 21:16:53 +00:00
zhi
bddc7798d8 fix: add no-reply model to agents.defaults.models allowlist
Changes:
- Change default provider ID from 'dirigentway' to 'dirigent'
- Add no-reply model to agents.defaults.models allowlist during install
- Fix no-reply-api server default model name from 'dirigent-no-reply-v1' to 'no-reply'

This fixes the issue where dirigent/no-reply model was not showing in
'openclaw models list' and was being rejected as 'not allowed'.
2026-03-09 13:12:35 +00:00
zhi
1f846fa7aa fix: stop treating empty content as NO_REPLY, detect tool calls
Problems fixed:
1. before_message_write treated empty content (isEmpty) as NO_REPLY.
   Tool-call-only assistant messages (thinking + toolCall, no text)
   had empty extracted text, causing false NO_REPLY detection.
   In single-agent channels this immediately set turn to dormant,
   blocking all subsequent responses for the entire model run.

2. Added explicit toolCall/tool_call/tool_use detection in
   before_message_write — skip turn processing for intermediate
   tool-call messages entirely.

3. no-reply-api/server.mjs: default model name changed from
   'dirigent-no-reply-v1' to 'no-reply' to match the configured
   model id in openclaw.json, fixing model list discovery.

Changes:
- plugin/hooks/before-message-write.ts: toolCall detection + remove isEmpty
- plugin/hooks/message-sent.ts: remove isEmpty from wasNoReply
- no-reply-api/server.mjs: fix default model name
- dist/dirigent/index.ts: same fixes applied to monolithic build
- dist/no-reply-api/server.mjs: same model name fix
2026-03-09 12:00:36 +00:00
68c13d9eef Merge pull request 'feat: split dirigent_tools + human @mention override' (#14) from feat/split-tools-and-mention-override into main
Reviewed-on: #14
2026-03-08 08:02:27 +00:00
e5158cf039 Merge pull request 'fix(turn): preserve mention override during membership refresh' (#17) from debug/mention-override-reset into feat/split-tools-and-mention-override
Reviewed-on: #17
2026-03-08 08:02:12 +00:00
7e0f187f34 fix(turn): keep mention override speaker/order when membership refresh runs 2026-03-08 07:56:26 +00:00
a995b7d6bf debug(turn): add mention-override and ensureTurnOrder state transition logs 2026-03-08 07:38:33 +00:00
05702941c1 Merge pull request 'feat: start NEW_FEAT refactor (channel resolver + decision input modules)' (#16) from feat/new-feat-refactor-start into feat/split-tools-and-mention-override
Reviewed-on: #16
2026-03-08 07:25:40 +00:00
ffaf7a89e5 docs(feat): merge NEW_FEAT into FEAT and remove NEW_FEAT.md 2026-03-08 07:24:59 +00:00
3cde4a7795 refactor(policy): move policy CRUD to /dirigent_policy slash command with explicit channelId 2026-03-08 07:17:23 +00:00
d497631b99 refactor(tools): drop guild member-list tool and remove dirigent_ prefix from tool names 2026-03-08 07:05:37 +00:00
bb10d6913e fix(plugin): start moderator reliably and colocate no-reply-api under plugin dir 2026-03-08 06:57:22 +00:00
a2781139b0 style(installer): adopt colored tabbed step output format 2026-03-08 06:45:32 +00:00
4d50826f2a refactor(installer): rename to install.mjs, remove record/restore flow, add --no-reply-port and wire config port 2026-03-08 06:38:33 +00:00
7640e80373 refactor(installer): detect existing install via plugins.entries.dirigent registration 2026-03-08 06:25:07 +00:00
6124ceec7a docs(cleanup): mark discord-control-api references as historical after in-plugin migration 2026-03-08 06:21:26 +00:00
e6b445fb97 chore(cleanup): remove discord-control-api sidecar and stale config/docs hooks 2026-03-08 06:19:41 +00:00
0806edb7d9 feat(cache): include source and guildId metadata in channel member cache 2026-03-08 06:15:19 +00:00
12b0f4c6cc feat(turn-init): persist channel bot membership cache to local file 2026-03-08 06:13:38 +00:00
307235e207 feat(turn-init): use internal Discord channel member resolver for bootstrap (no public tool) 2026-03-08 06:08:51 +00:00
121786e6e3 refactor(tools): remove channel-member-list public tool (internal-use only) 2026-03-08 05:53:38 +00:00
3e35da83ca feat(discord-tools): replace sidecar API with direct Discord REST + channel member listing 2026-03-08 05:50:10 +00:00
63009f3925 refactor(plugin): extract shared session state and decision pruning 2026-03-08 05:43:52 +00:00
ea501ccef8 refactor(plugin): extract no-reply child process lifecycle module 2026-03-08 05:39:27 +00:00
0c06aeb36c refactor(plugin): extract common utils and moderator discord helpers 2026-03-08 05:31:08 +00:00
f9ac401877 refactor(plugin): extract turn bootstrap/cache helpers into core module 2026-03-08 05:25:53 +00:00
05b902c0b2 refactor(plugin): extract identity and mention helpers into core modules 2026-03-08 05:19:40 +00:00
e258e99ed2 refactor(plugin): extract live config and policy store modules 2026-03-07 22:38:11 +00:00
d021b0c06c refactor(plugin): extract discord/policy tool registration module 2026-03-07 22:33:28 +00:00
3a8b85eb7b refactor(plugin): extract dirigent slash command and fix recursive prepare copy 2026-03-07 22:27:03 +00:00
b63c1dfe94 refactor(plugin): extract message_received hook and slim index imports 2026-03-07 22:24:48 +00:00
5c4340d5a9 refactor(plugin): extract before_prompt_build and before_message_write hooks 2026-03-07 22:21:16 +00:00
c15ea0d471 refactor(plugin): extract before_model_resolve and message_sent hooks 2026-03-07 22:18:09 +00:00
96a1f18d1b refactor(plugin): extract channel/decision parsing modules and unify channelId resolution 2026-03-07 22:12:27 +00:00
b7381395fe Merge pull request 'fix: align no-reply-api install path with plugin runtime expectation' (#15) from fix/no-reply-api-install-path into feat/split-tools-and-mention-override
Reviewed-on: #15
2026-03-07 19:24:26 +00:00
e8de773ee4 fix(installer): install no-reply API at expected plugins/no-reply-api path 2026-03-07 19:22:11 +00:00
zhi
fc2cdf0eee refactor: simplify openclaw dir resolution priority
Priority: --openclaw-profile-path arg >  env > ~/.openclaw
Remove openclaw CLI command attempts, simplify to 3 clear sources
2026-03-07 18:39:55 +00:00
zhi
211ad9246f feat: wait for human reply (waitIdentifier 👤)
- Add waitIdentifier config (default: 👤) to DirigentConfig and plugin schema
- Prompt injection: tells agents to end with 👤 when they need a human reply,
  warns to use sparingly (only when human is actively participating)
- Detection in before_message_write and message_sent hooks
- Turn manager: new waitingForHuman state
  - checkTurn() blocks all agents when waiting
  - onNewMessage() clears state on human message
  - Non-human messages ignored while waiting
  - resetTurn() also clears waiting state
- All agents routed to no-reply model during waiting state
- Update docs (FEAT.md, CHANGELOG.md, TASKLIST.md, README.md)
2026-03-07 17:32:28 +00:00
zhi
e4454bfc1a refactor: remove turn tools, rename discord tools, rewrite installer
- Remove turn management tools (turn-status/advance/reset) — internal only,
  accessible via /dirigent slash commands
- Rename discord tools: dirigent_discord_channel_create,
  dirigent_discord_channel_update, dirigent_discord_member_list
- Rewrite install script:
  - Dynamic OpenClaw dir resolution (OPENCLAW_DIR env → openclaw CLI → ~/.openclaw)
  - Plugin installed to $(openclaw_dir)/plugins/dirigent
  - New --update mode: git pull from latest branch + reinstall
  - Cleaner uninstall: removes installed plugin files
- Update docs (FEAT.md, README.md, CHANGELOG.md, TASKLIST.md)
2026-03-07 17:24:36 +00:00
zhi
7b93db3ed9 docs: replace NEW_FEAT.md with comprehensive FEAT.md, update README
- Delete NEW_FEAT.md
- Create FEAT.md with complete feature documentation across all versions
- Update README.md to reflect individual tool names and add @mention override
2026-03-07 17:04:42 +00:00
zhi
0729e83b38 feat: split dirigent_tools into individual tools + human @mention override
Feature 1: Split dirigent_tools
- Replace monolithic dirigent_tools (9 actions) with 9 individual tools
- Discord: dirigent_channel_create, dirigent_channel_update, dirigent_member_list
- Policy: dirigent_policy_get, dirigent_policy_set, dirigent_policy_delete
- Turn: dirigent_turn_status, dirigent_turn_advance, dirigent_turn_reset
- Extract shared executeDiscordAction() helper

Feature 2: Human @mention override
- When humanList user @mentions agents, temporarily override turn order
- Only mentioned agents cycle, ordered by their turn order position
- Original order restores when cycle returns to first agent or all NO_REPLY
- New: setMentionOverride(), hasMentionOverride(), extractMentionedUserIds()
- New: buildUserIdToAccountIdMap() for reverse userId→accountId resolution

Bump version to 0.3.0
2026-03-07 16:55:01 +00:00
nav
bbd18cd90c docs: add new feature notes 2026-03-07 16:46:33 +00:00
nav
d55eac8dc1 docs: add human mention override requirement 2026-03-05 11:34:07 +00:00
ac65d7e036 Merge pull request 'fix: resolve pluginDir correctly and ensure no-reply-api is copied during install' (#13) from no-reply-api-fix into main
Reviewed-on: #13
2026-03-03 20:25:21 +00:00
zhi
5c9c979f26 fix: resolve pluginDir correctly and ensure no-reply-api is copied during install
- Use import.meta.url instead of api.resolvePath('.') to get script directory
- Add debug logging for no-reply-api and moderator bot startup
- Copy no-reply-api to dist during installation
2026-03-03 20:23:29 +00:00
3d91d7ed95 Merge pull request 'dev/fix-issues' (#12) from dev/fix-issues into main
Reviewed-on: #12
2026-03-03 18:41:27 +00:00
zhi
92799176bf fix(install): fix uninstall order to satisfy config validation
- Remove from plugins.allow BEFORE deleting plugins.entries.dirigent
- OpenClaw validates that allow[] entries must exist in entries{}
- New order: allow → entry → paths → provider
2026-03-03 18:28:44 +00:00
zhi
cd0ce6a910 fix(install): use unsetPath for plugin entry removal
- Use openclaw config unset instead of get-modify-set
- Avoids triggering full plugins config validation
- Handles both added and replaced entries uniformly
2026-03-03 18:23:01 +00:00
zhi
a177150554 fix(install): uninstall always deletes plugin entry
- For uninstall, always delete plugins.entries.dirigent
- Don't try to restore old config (which may fail validation due to missing required fields)
- Provider restoration still works (replaced providers are restored)
2026-03-03 18:20:48 +00:00
zhi
b79cc1eb84 fix: use api.resolvePath for plugin directory
- Replace import.meta.url with api.resolvePath('.') for reliable path resolution
- Fixes no-reply API not starting due to incorrect pluginDir calculation
2026-03-03 18:16:47 +00:00
zhi
ed5ffd6c53 fix(install): clone function handle undefined
- clone(undefined) now returns undefined instead of throwing JSON.parse error
- This fixes install when plugins.entries.dirigent doesn't exist yet
2026-03-03 18:06:14 +00:00
zhi
be8fe835d0 fix(install): handle 'undefined' string from openclaw config get
- getJson now treats 'undefined' string as missing value
- Add try-catch to JSON.parse for robustness
2026-03-03 17:57:34 +00:00
zhi
91cd402627 fix(install): remove duplicate function definition
- Remove empty duplicate runOpenclaw function that caused syntax error
2026-03-03 17:02:08 +00:00
84b0363d59 Merge pull request 'dev/dirigent-tasks' (#11) from dev/dirigent-tasks into main
Reviewed-on: #11
2026-03-03 16:54:44 +00:00
zhi
83c03b6934 feat(install): idempotent install/uninstall
- Repeat install = reinstall: auto-detect existing install, uninstall first, then install
- Repeat uninstall = no-op: if no install record found, exit 0 with message
- Uses spawnSync to re-exec uninstall phase during reinstall
2026-03-03 16:39:40 +00:00
zhi
6423dbcdfa fix(install): handle plugins.allow in install/uninstall
- Install: add 'dirigent' to plugins.allow if not present, record in delta
- Uninstall: remove 'dirigent' from plugins.allow if it was added by us
- Delta tracking preserves other allowlist entries
2026-03-03 16:36:33 +00:00
zhi
d378e27be9 fix(install): delta-tracking uninstall + remove sh script
- Rewrite install/uninstall to use delta-tracking (added/replaced/removed)
- Install records only what the plugin changed:
  * added: plugin新增的配置(卸载时删除)
  * replaced: plugin覆盖的旧配置(卸载时恢复)
- Uninstall only affects plugin-managed keys, preserving user changes elsewhere
- Remove install-dirigent-openclaw.sh to avoid confusion
2026-03-03 16:34:50 +00:00
zhi
2ea4f59a1e build: support both registry publish and git clone install
- Add root package.json with files[] for registry publishing
- Add prepare script to sync dist/ from plugin/
- Add postinstall to auto-run install script
- Modify install script: auto-create dist/ if missing (git clone case)
- Plugin works in both scenarios:
  * npm install @hangman-lab/dirigent -> uses bundled dist/
  * git clone + node scripts/install-dirigent-openclaw.mjs -> auto-creates dist/
2026-03-03 16:24:05 +00:00
zhi
8e26744162 feat: lifecycle management - no-reply API + moderator bot follow gateway start/stop
- Added gateway_start hook: spawns no-reply API as child process, then starts moderator bot
- Added gateway_stop hook: kills no-reply API process, stops moderator bot
- No-reply API server.mjs is located relative to plugin dir via import.meta.url
- Moderator presence moved from register() to gateway_start for proper lifecycle
2026-03-03 16:01:34 +00:00
zhi
cb58e3d951 docs: add schedulingIdentifier to CONFIG.example.json 2026-03-03 14:23:52 +00:00
zhi
fd1bf449a4 refine: cleanup remaining whispergate refs, improve docs and TASKLIST formatting
- Fix enableWhispergatePolicyTool → enableDirigentPolicyTool in config schema and example
- Fix whisper-gateway → dirigentway in install script
- Add v0.2.0 changelog entry
- Improve README with scheduling identifier docs and English text
- Clean up plugin README with moderator handoff format docs
- Reformat TASKLIST with cleaner done markers
2026-03-03 10:13:39 +00:00
zhi
af33d747d9 feat: complete Dirigent rename + all TASKLIST items
- Task 1: Identity prompt now includes Discord userId
- Task 2: Added configurable schedulingIdentifier (default: ➡️)
- Task 3: Moderator handoff uses <@userId>+identifier instead of semantic messages
- Task 4: All prompts/comments/help text converted to English
- Task 5: Full project rename WhisperGate → Dirigent across all files

Breaking: config key changed from plugins.entries.whispergate to plugins.entries.dirigent
Breaking: channel policies file renamed to dirigent-channel-policies.json
Breaking: tool name changed from whispergate_tools to dirigent_tools
2026-03-03 10:10:27 +00:00
zhi
2afb982c04 docs: add tasklist for upcoming fixes 2026-03-03 09:59:19 +00:00
zhi
a18168749b docs: update README for turn-based features 2026-03-03 09:59:12 +00:00
329f6ed490 Merge pull request 'fix: advance turn in before_message_write to prevent race condition' (#10) from fix/turn-advance-race-condition into main
Reviewed-on: nav/WhisperGate#10
2026-03-03 03:47:35 +00:00
zhi
cf9be60145 fix: advance turn in before_message_write to prevent race condition
When a speaker finishes with an end symbol, the turn was only advanced
in the message_sent hook. But by that time, the message had already been
broadcast to other agents, whose before_model_resolve ran with the old
turn state, causing them to be blocked by the turn gate (forced no-reply).

Fix:
- Move turn advance for both NO_REPLY and end-symbol cases to
  before_message_write, which fires before the message is broadcast.
- Guard 1: Only the current speaker can advance the turn (accountId check).
- Guard 2: Only process assistant messages (role check). before_message_write
  fires for incoming user messages too, which contain end symbols from other
  agents and would cause cascading turn advances.
- Use sessionTurnHandled set to prevent double-advancing in message_sent.
2026-03-02 11:34:40 +00:00
zhi
6d17e6a911 fix: advance turn in before_message_write to prevent race condition
When a speaker finishes with an end symbol, the turn was only advanced
in the message_sent hook. But by that time, the message had already been
broadcast to other agents, whose before_model_resolve ran with the old
turn state, causing them to be blocked by the turn gate (forced no-reply).

Fix: Move turn advance for both NO_REPLY and end-symbol cases to
before_message_write, which fires before the message is broadcast.
Use sessionTurnHandled set to prevent double-advancing in message_sent.
2026-03-02 11:04:41 +00:00
aeecb534b7 Merge pull request 'fix: channelId extraction, sender identification, and per-channel turn order' (#9) from fix/turn-gate-and-handoff into main
Reviewed-on: orion/WhisperGate#9
2026-03-01 11:12:17 +00:00
zhi
d90083317b fix: channelId extraction, sender identification, and per-channel turn order
- Fix channelId extraction: ctx.channelId is platform name ('discord'), not
  the Discord channel snowflake. Now extracts from conversation_label field
  ('channel id:123456') and sessionKey fallback (':channel:123456').

- Fix extractDiscordChannelId: support 'discord:channel:xxx' format in
  addition to 'channel:xxx' for conversationId/event.to fields.

- Fix sender identification in message_received: event.from returns channel
  target, not sender ID. Now uses event.metadata.senderId for humanList
  matching so human messages correctly reset turn order.

- Fix per-channel turn order: was using all server-wide bot accounts from
  bindings, causing deadlock when turn landed on bots not in the channel.
  Now dynamically tracks which bot accounts are seen per channel via
  message_received and only includes those in turn order.

- Always save sessionChannelId/sessionAccountId mappings in before_model_resolve
  regardless of turn check result, so downstream hooks can use them.

- Add comprehensive debug logging to message_sent hook.
2026-03-01 11:08:41 +00:00
zhi
a4bc9990db fix: add sessionAccountId mapping and use in before_message_write
- Add sessionAccountId Map to track sessionKey -> accountId
- Save accountId in before_model_resolve when resolving accountId
- Use sessionChannelId/sessionAccountId fallback in before_message_write
2026-03-01 09:16:55 +00:00
zhi
435a7712b8 fix: add sessionChannelId mapping for message_sent
- Add sessionChannelId Map to track sessionKey -> channelId
- Save channelId in before_model_resolve when we have derived.channelId
- Fix message_sent to use sessionChannelId fallback when ctx.channelId is undefined
- Add debug logging to message_sent
2026-03-01 09:13:03 +00:00
zhi
9e754d32cf fix: make before_message_write synchronous
- Remove async/await from before_message_write hook
- Use fire-and-forget for sendModeratorMessage (void ... .catch())
- OpenClaw's before_message_write is synchronous, not async
2026-03-01 04:59:45 +00:00
zhi
692111eeda fix: implement turn gate and handoff improvements
1. Fix channelId priority: use ctx.channelId > conv.chat_id > conv.channel_id
2. Add sessionAllowed state: track if session was allowed (true) or forced no-reply (false)
3. Add sessionInjected Set: implement one-time prependContext injection
4. Add before_message_write hook: detect NO_REPLY and handle turn advancement
   - forced no-reply: don't advance turn
   - allowed + NO_REPLY: advance turn + handoff
   - dormant state: don't trigger handoff
5. message_sent: continues to handle real messages with end symbols
2026-02-28 21:44:53 +00:00
zhi
80439b0912 Revert "Merge pull request 'fix: use systemPrompt instead of prependContext for end marker instruction' (#5) from fix/moderator-and-system-prompt into feat/turn-based-speaking"
This reverts commit 6a81f75fd0, reversing
changes made to 86fdc63802.
2026-02-28 19:34:37 +00:00
zhi
7fdc7952b7 Merge pull request 'fix: add turn check debug logs + one-time prompt injection' (#6) from fix/turn-check-debug-and-onetime-inject into feat/turn-based-speaking 2026-02-28 19:27:59 +00:00
zhi
f03d32e15f fix: add turn check debug logs + one-time prompt injection
1. Turn check improvements:
   - Add debug logs for ctx.agentId, resolved accountId, turnOrder length
   - Fallback to ctx.accountId if resolveAccountId fails
   - Add resolveDiscordUserId debug logs for handoff troubleshooting

2. One-time prompt injection:
   - Add sessionInjected Set to track injected sessions
   - Use prependContext (not systemPrompt) but only inject once per session
   - Skip subsequent injections with debug log
2026-02-28 19:27:39 +00:00
zhi
6a81f75fd0 Merge pull request 'fix: use systemPrompt instead of prependContext for end marker instruction' (#5) from fix/moderator-and-system-prompt into feat/turn-based-speaking 2026-02-28 19:25:23 +00:00
zhi
81758b9a54 fix: use systemPrompt instead of prependContext for end marker instruction
- Change before_prompt_build hook to return systemPrompt instead of prependContext
- This ensures the end marker instruction is injected once per session as a system prompt, not repeatedly prepended to each user message
- Add moderatorBotToken to CONFIG.example.json for documentation
2026-02-28 19:24:50 +00:00
zhi
86fdc63802 fix: moderator presence reconnect loop
Root causes:
1. Multiple plugin subsystems each called startModeratorPresence,
   creating competing WebSocket connections to the same bot token.
   Discord only allows one connection per bot → 4008 rate limit →
   infinite reconnect loop (1000+ connects → token reset by Discord)

2. Invalid session (op 9) handler called scheduleReconnect, but the
   new connection would also get kicked → cascading reconnects

Fixes:
- Singleton guard: startModeratorPresence is a no-op if already started
- cleanup() nullifies old ws handlers before creating new connection
- Stale ws check: all callbacks verify they belong to current ws
- Exponential backoff with cap (max 60s) instead of fixed 2-5s delay
- heartbeat ACK tracking: detect zombie connections
- Non-recoverable codes (4004) properly stop all reconnection
2026-02-28 18:49:17 +00:00
zhi
385990ab90 fix: turn check runs independently of evaluateDecision
Turn order should be enforced for ALL messages, not just non-human ones.
Previously, human messages bypassed turn check because they go through
human_list_sender path with shouldUseNoReply=false. Now turn check
always runs when channel has turn state.
2026-02-28 12:41:46 +00:00
zhi
a6f2be44b7 feat: moderator bot presence via Discord Gateway
Use Node.js built-in WebSocket to maintain a minimal Discord Gateway
connection for the moderator bot, keeping it 'online' with a
'Watching Moderating' status. Handles heartbeat, reconnect, and resume.

Also fix package-plugin.mjs to include moderator-presence.ts in dist.
2026-02-28 12:33:58 +00:00
zhi
fb50b62509 fix: include turn-manager.ts in package-plugin.mjs
The packaging script didn't copy turn-manager.ts to dist, causing
'Cannot find module ./turn-manager.js' at gateway startup.
2026-02-28 12:27:58 +00:00
zhi
8c8757e9a7 fix: add moderatorBotToken to plugin configSchema
The plugin's openclaw.plugin.json has additionalProperties:false,
so any config field not in the schema causes gateway startup failure.
2026-02-28 12:11:04 +00:00
zhi
54ff78cffe feat: moderator bot for turn handoff messages
Add a dedicated moderator Discord bot that sends handoff messages when
the current speaker says NO_REPLY. This solves the wakeup problem.

Flow:
1. Agent A is current speaker, receives message
2. Agent A responds with NO_REPLY
3. Plugin detects NO_REPLY in message_sent hook, advances turn to Agent B
4. Plugin sends via moderator bot: '轮到(@AgentB)了,如果没有想说的请直接回复NO_REPLY'
5. This real Discord message triggers Agent B's session
6. Turn manager allows Agent B to respond

Implementation:
- moderatorBotToken config field for the moderator bot's Discord token
- userIdFromToken() extracts Discord user ID from bot token (base64)
- resolveDiscordUserId() maps accountId → Discord user ID via account tokens
- sendModeratorMessage() calls Discord REST API directly
- message_received ignores moderator bot messages (transparent to turn state)
- Moderator bot is NOT in the turn order
2026-02-28 12:10:52 +00:00
zhi
476308d0df refactor: auto-managed turn order + dormant state + identity injection
Turn system redesign:
- Turn order auto-populated from config bindings (all bot accounts)
- No manual turnOrder config needed
- Humans (humanList) excluded from turn order automatically
- Dormant state: when all agents NO_REPLY in a cycle, currentSpeaker=null
- Reactivation: any new message wakes the system
  - Human message → start from first in order
  - Bot not in order → start from first
  - Bot in order → next after sender
- Skip already-NO_REPLY'd agents when advancing

Identity injection:
- Group chat prompts now include agent identity
- Format: '你是 {name}(Discord 账号: {accountId})'

Other:
- Remove turnOrder from ChannelPolicy (no longer configurable)
- Add TURN-WAKEUP-PROBLEM.md documenting the NO_REPLY wake-up challenge
- Update message_received to call onNewMessage with proper human detection
- Update message_sent to call onSpeakerDone with NO_REPLY tracking
2026-02-28 12:10:52 +00:00
zhi
1d8881577d feat: turn-based speaking + slash commands + enhanced prompts
1. Rename tool: whispergateway_tools → whispergate_tools

2. Turn-based speaking mechanism:
   - New turn-manager.ts maintains per-channel turn state
   - ChannelPolicy新增turnOrder字段配置发言顺序
   - before_model_resolve hook检查当前agent是否为发言人
   - 非当前发言人直接切换到no-reply模型
   - message_sent hook检测结束符或NO_REPLY时推进turn
   - message_received检测到human消息时重置turn

3. 注入提示词增强:
   - buildEndMarkerInstruction增加isGroupChat参数
   - 群聊时追加规则:与自己无关时主动回复NO_REPLY

4. Slash command支持:
   - /whispergate status - 查看频道策略
   - /whispergate turn-status - 查看轮流状态
   - /whispergate turn-advance - 手动推进轮流
   - /whispergate turn-reset - 重置轮流顺序
2026-02-28 12:10:52 +00:00
52a613fdcc Merge pull request 'fix: add default values for optional config fields' (#3) from feat/whispergate-mvp into main
Reviewed-on: orion/WhisperGate#3
2026-02-27 15:31:32 +00:00
882f62eae7 Merge pull request 'WhisperGate MVP: no-reply API + plugin rule gate' (#1) from feat/whispergate-mvp into main
Reviewed-on: orion/WhisperGate#1
2026-02-25 22:07:07 +00:00
76 changed files with 7925 additions and 2412 deletions

View File

@@ -1,20 +0,0 @@
# Changelog
## 0.1.0-mvp
- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
- Added optional bearer auth (`AUTH_TOKEN`)
- Added WhisperGate plugin with deterministic rule gate
- Added discord-specific 🔚 prompt injection for bypass/end-symbol paths
- Added containerization (`Dockerfile`, `docker-compose.yml`)
- Added helper scripts for smoke/dev lifecycle and rule validation
- Added no-touch config rendering and integration docs
- Added installer script with rollback (`scripts/install-whispergate-openclaw.sh`)
- supports `--install` / `--uninstall`
- uninstall restores all recorded changes
- writes install/uninstall records under `~/.openclaw/whispergate-install-records/`
- Added discord-control-api with:
- `channel-private-create` (create private channel for allowlist)
- `channel-private-update` (update allowlist/overwrites for existing channel)
- `member-list` (guild members list with pagination + optional field projection)
- guardrails: action mode validation, id-list limits, response-size limit

393
DESIGN.md Normal file
View File

@@ -0,0 +1,393 @@
# Dirigent — Design Spec (v2)
## Overview
Dirigent is an OpenClaw plugin that orchestrates turn-based multi-agent conversations in Discord. It manages who speaks when, prevents out-of-turn responses, and coordinates structured discussions between agents.
**Optional integrations** (Dirigent must function fully without either):
- **padded-cell** — enables auto-registration of agent identities from `ego.json`
- **yonexus** — enables cross-instance multi-agent coordination (see §8)
---
## 1. Identity Registry
### Storage
A JSON file (path configurable via plugin config, default `~/.openclaw/dirigent-identity.json`).
Each entry:
```json
{
"discordUserId": "123456789012345678",
"agentId": "home-developer",
"agentName": "Developer"
}
```
### Registration Methods
#### Manual — Tool
Agents call `dirigent-register` to add or update their own entry. `agentId` is auto-derived from the calling session; the agent only provides `discordUserId` and optionally `agentName`.
#### Manual — Control Page
The `/dirigent` control page exposes a table with inline add, edit, and delete.
#### Auto — padded-cell Integration
On gateway startup, if padded-cell is loaded, Dirigent reads `~/.openclaw/ego.json`.
**Detection**: check whether `ego.json`'s `columns` array contains `"discord-id"`. If not, treat padded-cell as absent and skip auto-registration entirely.
**ego.json structure** (padded-cell's `EgoData` format):
```json
{
"columns": ["discord-id", "..."],
"publicColumns": ["..."],
"publicScope": {},
"agentScope": {
"home-developer": { "discord-id": "123456789012345678" },
"home-researcher": { "discord-id": "987654321098765432" }
}
}
```
**Scan logic**:
1. If `columns` does not include `"discord-id"`: skip entirely.
2. For each key in `agentScope`: key is the `agentId`.
3. Read `agentScope[agentId]["discord-id"]`. If present and non-empty: upsert into identity registry (existing entries preserved, new ones appended).
4. Agent name defaults to `agentId` if no dedicated name column exists.
The control page shows a **Re-scan padded-cell** button when padded-cell is detected.
---
## 2. Channel Modes
**Default**: any channel Dirigent has not seen before is treated as `none`.
| Mode | Description | How to set |
|------|-------------|------------|
| `none` | No special behavior. Turn-manager disabled. | Default · `/set-channel-mode none` · control page |
| `work` | Agent workspace channel. Turn-manager disabled. | `create-work-channel` tool only |
| `report` | Agents post via message tool only; not woken by incoming messages. | `create-report-channel` tool · `/set-channel-mode report` · control page |
| `discussion` | Structured agent discussion. | `create-discussion-channel` tool only |
| `chat` | Ongoing multi-agent chat. | `create-chat-channel` tool · `/set-channel-mode chat` · control page |
**Mode-change restrictions**:
- `work` and `discussion` are locked — only settable at channel creation by their respective tools. Cannot be changed to another mode; no other mode can be changed to them.
- `none`, `chat`, and `report` are freely switchable via `/set-channel-mode` or the control page.
### Mode → Turn-Manager State
| Mode | Agent Count | Turn-Manager State |
|------|-------------|-------------------|
| `none` | any | `disabled` |
| `work` | any | `disabled` |
| `report` | any | `dead` |
| `discussion` | 1 | `disabled` |
| `discussion` | 2 | `normal` |
| `discussion` | 3+ | `shuffle` |
| `discussion` | concluded | `archived` |
| `chat` | 1 | `disabled` |
| `chat` | 2 | `normal` |
| `chat` | 3+ | `shuffle` |
---
## 3. Channel Creation Tools & Slash Commands
### Tools
#### `create-chat-channel`
Creates a new Discord channel in the caller's guild and sets its mode to `chat`.
| Parameter | Description |
|-----------|-------------|
| `name` | Channel name |
| `participants` | Discord user IDs to add (optional; moderator bot always added) |
#### `create-report-channel`
Creates a new Discord channel and sets its mode to `report`.
| Parameter | Description |
|-----------|-------------|
| `name` | Channel name |
| `members` | Discord user IDs to add (optional) |
#### `create-work-channel`
Creates a new Discord channel and sets its mode to `work`. Mode is permanently locked.
| Parameter | Description |
|-----------|-------------|
| `name` | Channel name |
| `members` | Additional Discord user IDs to add (optional) |
#### `create-discussion-channel`
See §5 for full details.
#### `dirigent-register`
Registers or updates the calling agent's identity entry.
| Parameter | Description |
|-----------|-------------|
| `discordUserId` | The agent's Discord user ID |
| `agentName` | Display name (optional; defaults to agentId) |
### Slash Command — `/set-channel-mode`
Available in any Discord channel where the moderator bot is present.
```
/set-channel-mode <mode>
```
- Allowed values: `none`, `chat`, `report`
- Rejected with error: `work`, `discussion` (locked to creation tools)
- If the channel is currently `work` or `discussion`: command is rejected, mode is locked
---
## 4. Turn-Manager
### Per-Channel States
| State | Behavior |
|-------|----------|
| `disabled` | All turn-manager logic bypassed. Agents respond normally. |
| `dead` | Discord messages are not routed to any agent session. |
| `normal` | Speaker list rotates in fixed order. |
| `shuffle` | After the last speaker completes a full cycle, the list is reshuffled. Constraint: the previous last speaker cannot become the new first speaker. |
| `archived` | Channel is sealed. No agent is woken. New Discord messages receive a moderator auto-reply: "This channel is archived and no longer active." |
### Speaker List Construction
For `discussion` and `chat` channels:
1. Moderator bot fetches all Discord channel members via Discord API.
2. Each member's Discord user ID is resolved via the identity registry. Members identified as agents are added to the speaker list.
3. At each **cycle boundary** (after the last speaker in the list completes their turn), the list is rebuilt:
- Re-fetch current Discord channel members.
- In `normal` mode: existing members retain relative order; new agents are appended.
- In `shuffle` mode: the rebuilt list is reshuffled, with the constraint above.
### Turn Flow
#### `before_model_resolve`
1. Determine the active speaker for this channel (from turn-manager state).
2. Record the current channel's latest Discord message ID as an **anchor** (used later for delivery confirmation).
3. If the current agent is the active speaker: allow through with their configured model.
4. If not: route to `dirigent/no-reply` — response is suppressed.
#### `agent_end`
1. Check if the agent that finished is the active speaker. If not: ignore.
2. Extract the final reply text from `event.messages`: find the last message with `role === "assistant"`, then concatenate the `text` field from all `{type: "text"}` parts in its `content` array.
3. Classify the turn:
- **Empty turn**: text is `NO_REPLY`, `NO`, or empty/whitespace-only.
- **Real turn**: anything else.
4. Record the result for dormant tracking.
**If empty turn**: advance the speaker pointer immediately — no Discord delivery to wait for.
**If real turn**: wait for Discord delivery confirmation before advancing.
### Delivery Confirmation (Real Turns)
`agent_end` fires when OpenClaw has dispatched the message, not when Discord has delivered it. OpenClaw also splits long messages into multiple Discord messages — the next agent must not be triggered before the last fragment arrives.
**Tail-match polling**:
1. Take the last 40 characters of the final reply text as a **tail fingerprint**.
2. Poll `GET /channels/{channelId}/messages?limit=20` at a short interval, filtering to messages where:
- `message.id > anchor` (only messages from this turn onward)
- `message.author.id === agentDiscordUserId` (only from this agent's Discord account)
3. Take the most recent matching message. If its content ends with the tail fingerprint: match confirmed.
4. On match: advance the speaker pointer and post `{schedule_identifier}` then immediately delete it.
**Interruption**: if any message from a non-current-speaker appears in the channel during the wait, cancel the tail-match and treat the event as a wake-from-dormant (see below).
**Timeout**: if no match within 15 seconds (configurable), log a warning and advance anyway to prevent a permanently stalled turn.
**Fingerprint length**: 40 characters (configurable). The author + anchor filters make false matches negligible at this length.
### Dormant Stage
#### Definitions
- **Cycle**: one complete pass through the current speaker list from first to last.
- **Empty turn**: final reply text is `NO_REPLY`, `NO`, or empty/whitespace-only.
- **Cycle boundary**: the moment the last agent in the current list completes their turn.
#### Intent
Dormant stops the moderator from endlessly triggering agents when no one has anything to say. Entering dormant requires **unanimous** empty turns — any single real message is a veto and the cycle continues. When a new Discord message arrives (from a human or an agent via the message tool), it signals a new topic; the channel wakes and every agent gets another chance to respond.
#### Trigger
At each cycle boundary:
1. Re-fetch Discord channel members and build the new speaker list.
2. Check whether any new agents were added to the list.
3. Check whether **all agents who completed a turn in this cycle** sent empty turns.
Enter dormant **only if both hold**:
- All agents in the completed cycle sent empty turns.
- No new agents were added at this boundary.
If new agents joined: reset empty-turn tracking and start a fresh cycle — do not enter dormant even if all existing agents sent empty.
#### Dormant Behavior
- `currentSpeaker``null`.
- Empty-turn history is cleared.
- Moderator stops posting `{schedule_identifier}`.
#### Wake from Dormant
- **Trigger**: any new Discord message in the channel (human or agent via message tool).
- `currentSpeaker` → first agent in the speaker list.
- Moderator posts `{schedule_identifier}` then deletes it.
- A new cycle begins. Agents that have nothing to say emit empty turns; if all pass again, the channel returns to dormant.
#### Edge Cases
| Scenario | Behavior |
|----------|----------|
| Agent leaves mid-cycle | Turn is skipped; agent removed at next cycle boundary. Dormant check counts only agents who completed a turn. |
| New agent joins mid-cycle | Not added until next cycle boundary. Does not affect current dormant check. |
| Shuffle mode | Reshuffle happens after the dormant check at cycle boundary. Dormant logic is identical to `normal`. |
| Shuffle + new agents | New agents appended before reshuffling. Since new agents were found, dormant is suppressed; full enlarged list starts a new shuffled cycle. |
---
## 5. Discussion Mode
### Creation — `create-discussion-channel`
Called by an agent (the **initiator**). `initiator` is auto-derived from the calling session.
| Parameter | Description |
|-----------|-------------|
| `callback-guild` | Guild ID of the initiator's current channel. Error if moderator bot lacks admin in this guild. |
| `callback-channel` | Channel ID of the initiator's current channel. Error if not a Discord group channel. |
| `discussion-guide` | Minimum context: topic, goals, completion criteria. |
| `participants` | List of Discord user IDs for participating agents. |
### Discussion Lifecycle
```
Agent calls create-discussion-channel
Moderator creates new private Discord channel, adds participants
Moderator posts discussion-guide into the channel → wakes participant agents
Turn-manager governs the discussion (normal / shuffle based on participant count)
├─[dormant]──► Moderator posts reminder to initiator:
│ "Discussion is idle. Please summarize and call discussion-complete."
▼ initiator calls discussion-complete
Turn-manager state → archived
Moderator auto-replies to any new messages: "This discussion is closed."
Moderator posts summary file path to callback-channel
```
### `discussion-complete` Tool
| Parameter | Description |
|-----------|-------------|
| `discussion-channel` | Channel ID where the discussion took place |
| `summary` | File path to the summary (must be under `{workspace}/discussion-summary/`) |
Validation:
- Caller must be the initiator of the specified discussion channel. Otherwise: error.
- Summary file must exist at the given path.
---
## 6. Control Page — `/dirigent`
HTTP route registered on the OpenClaw gateway. Auth: `gateway` (requires the same Bearer token as the gateway API; returns 401 without it).
### Sections
#### Identity Registry
- Table: discord-user-id / agent-id / agent-name
- Inline add, edit, delete
- **Re-scan padded-cell** button (shown only when padded-cell is detected)
#### Guild & Channel Configuration
- Lists all Discord guilds where the moderator bot has admin permissions.
- For each guild: all private group channels.
- Per channel:
- Current mode badge
- Mode dropdown (`none | chat | report`) — hidden for `work` and `discussion` channels
- `work` and `discussion` channels display mode as a read-only badge
- Channels unknown to Dirigent display as `none`
- Current turn-manager state and active speaker name (where applicable)
---
## 7. Migration from v1
| v1 Mechanic | v2 Replacement |
|-------------|----------------|
| End symbol (`🔚`) required in agent replies | Removed — agents no longer need end symbols |
| `before_message_write` drives turn advance | Replaced by `agent_end` hook |
| Moderator posts visible handoff message each turn | Moderator posts `{schedule_identifier}` then immediately deletes it |
| NO_REPLY detected from `before_message_write` content | Derived from last assistant message in `agent_end` `event.messages` |
| Turn advances immediately on agent response | Empty turns advance immediately; real turns wait for Discord delivery confirmation via tail-match polling |
---
## 8. Yonexus Compatibility (Future)
> Yonexus is a planned cross-instance WebSocket communication plugin (hub-and-spoke). Dirigent must work fully without it.
### Topology
```
Instance A (master) Instance B (slave) Instance C (slave)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Dirigent │◄──Yonexus──►│ Dirigent │◄──Yonexus──►│ Dirigent │
│ (authority) │ │ (relay) │ │ (relay) │
└──────────────┘ └──────────────┘ └──────────────┘
Authoritative state:
- Identity registry
- Channel modes & turn-manager states
- Speaker lists & turn pointers
- Discussion metadata
```
### Master / Slave Roles
**Master**:
- Holds all authoritative state.
- Serves read/write operations to slaves via Yonexus message rules.
- Executes all moderator bot actions (post/delete `{schedule_identifier}`, send discussion-guide, etc.).
**Slave**:
- No local state for shared channels.
- `before_model_resolve`: queries master to determine if this agent is the active speaker.
- `agent_end`: notifies master that the turn is complete (`agentId`, `channelId`, `isEmpty`).
- Master handles all speaker advancement and moderator actions.
### Message Rules (provisional)
```
dirigent::check-turn → { allowed: bool, currentSpeaker: string }
dirigent::turn-complete → { agentId, channelId, isEmpty }
dirigent::get-identity → identity registry entry for discordUserId
dirigent::get-channel-state → { mode, tmState, currentSpeaker }
```
### Constraints
- Without Yonexus: Dirigent runs in standalone mode with all state local.
- Role configured via plugin config: `dirigentRole: "master" | "slave"` (default: `"master"`).
- Slave instances skip all local state mutations.
- Identity registry, channel config, and control page are only meaningful on the master instance.

View File

@@ -1,4 +1,4 @@
.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up smoke-discord-control
.PHONY: check check-rules check-files smoke install
check:
cd plugin && npm run check
@@ -6,26 +6,11 @@ check:
check-rules:
node scripts/validate-rules.mjs
test-api:
node scripts/test-no-reply-api.mjs
up:
./scripts/dev-up.sh
down:
./scripts/dev-down.sh
check-files:
node scripts/check-plugin-files.mjs
smoke:
./scripts/smoke-no-reply-api.sh
render-config:
node scripts/render-openclaw-config.mjs
package-plugin:
node scripts/package-plugin.mjs
discord-control-up:
cd discord-control-api && node server.mjs
smoke-discord-control:
./scripts/smoke-discord-control.sh
install:
node scripts/install.mjs --install

143
README.md
View File

@@ -1,60 +1,117 @@
# WhisperGate
# Dirigent
Rule-based no-reply gate for OpenClaw.
Turn-management and moderation plugin for OpenClaw (Discord).
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
## What it does
WhisperGate adds a deterministic gate **before model selection**:
Dirigent adds deterministic routing and **turn-based speaking** for multi-agent Discord channels:
1. If message is not from Discord → skip gate
2. If sender is in bypass user list → skip gate
3. If message ends with configured end-symbol → skip gate
4. Otherwise switch this turn to a no-reply model/provider
- **Rule gate (`before_model_resolve`)**
- Non-speaker agents → routed to no-reply model (silent turn)
- Dormant channels → all agents suppressed until a human message wakes them
- Configurable per-channel mode: `chat`, `work`, `discussion`, `report`, `none`
The no-reply provider returns `NO_REPLY` for any input.
- **Turn management**
- Only the current speaker responds; others are silenced
- Turn advances when the current speaker ends with a configured symbol or returns `NO_REPLY`
- Full round of silence → channel enters **dormant** state
- Human message → wakes dormant channel, triggers first speaker
- **Moderator bot sidecar**
- Dedicated Discord Gateway connection (separate bot token) for real-time message push
- Sends schedule-trigger messages (`<@USER_ID>➡️`) to signal speaker turns
- Notifies the plugin via HTTP callback on new messages (wake/interrupt)
- **Discussion mode**
- Agents can initiate a structured discussion via `create-discussion-channel` tool
- Initiator calls `discussion-complete` to conclude; summary is posted to the callback channel
- **Channel management tools**
- `create-chat-channel`, `create-work-channel`, `create-report-channel` — create typed channels
- `create-discussion-channel`, `discussion-complete` — discussion lifecycle
- `dirigent-register` — register an agent identity
---
## Repo layout
- `plugin/` — OpenClaw plugin (before_model_resolve hook)
- `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY`
- `discord-control-api/` — Discord 管理扩展 API私密频道 + 成员列表)
- `docs/` — rollout, integration, run-mode notes
- `scripts/` — smoke/dev/helper checks
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`)
- `CHANGELOG.md` — milestone summary
## Quick start (no Docker)
```bash
cd no-reply-api
node server.mjs
```
Then render config snippet:
```bash
node scripts/render-openclaw-config.mjs
plugin/ OpenClaw plugin (hooks, tools, commands, web UI)
core/ Channel store, identity registry, moderator REST helpers
hooks/ before_model_resolve, agent_end, message_received
tools/ Agent-facing tools
commands/ Slash commands
web/ Control page + Dirigent API (HTTP routes)
services/ Sidecar process — spawned automatically by the plugin
main.mjs Unified entry point, routes /no-reply/* and /moderator/*
no-reply-api/ OpenAI-compatible server that always returns NO_REPLY
moderator/ Discord Gateway client + HTTP control endpoints
scripts/ Dev helpers
docs/ Architecture and integration notes
```
See `docs/RUN_MODES.md` for Docker mode.
Discord 扩展能力见:`docs/DISCORD_CONTROL.md`
---
## Development plan (incremental commits)
## Installation
- [x] Task 1: project docs + structure
- [x] Task 2: no-reply API MVP
- [x] Task 3: plugin MVP with rule chain
- [x] Task 4: sample config + quick verification scripts
- [x] Task 5: plugin rule extraction + hardening
- [x] Task 6: containerization + compose
- [x] Task 7: plugin usage notes
- [x] Task 8: sender normalization + TTL + one-shot decision
- [x] Task 9: auth-aware no-reply API
- [x] Task 10: smoke test helpers
- [x] Task 11: plugin structure checker
- [x] Task 12: rollout checklist
```bash
node scripts/install.mjs --install
```
This copies `plugin/` and `services/` into the OpenClaw plugin directory and registers skills.
---
## Sidecar
The sidecar (`services/main.mjs`) is spawned automatically when openclaw-gateway starts. It exposes:
| Path prefix | Description |
|---|---|
| `/no-reply/*` | No-reply model API (`/v1/chat/completions`, `/v1/responses`) |
| `/moderator/*` | Moderator bot control (`/send`, `/create-channel`, `/me`, …) |
| `/health` | Combined health check |
Port is configured via `sideCarPort` (default `8787`).
Smoke-test after gateway start:
```bash
make smoke
# or:
./scripts/smoke-no-reply-api.sh
```
---
## Plugin config
Key options (in `openclaw.json` under `plugins.entries.dirigent.config`):
| Key | Default | Description |
|---|---|---|
| `moderatorBotToken` | — | Discord bot token for the moderator/sidecar bot |
| `scheduleIdentifier` | `➡️` | Symbol appended to schedule-trigger mentions |
| `listMode` | `human-list` | `human-list` or `agent-list` |
| `humanList` | `[]` | Discord user IDs treated as humans (bypass turn gate) |
| `agentList` | `[]` | Discord user IDs treated as agents (when `listMode=agent-list`) |
| `noReplyProvider` | `dirigent` | Provider ID for the no-reply model |
| `noReplyModel` | `no-reply` | Model ID for the no-reply model |
| `sideCarPort` | `8787` | Port the sidecar listens on |
| `debugMode` | `false` | Enable verbose debug logging |
| `debugLogChannelIds` | `[]` | Channel IDs that receive debug log messages |
| `channelPoliciesFile` | `~/.openclaw/dirigent-channel-policies.json` | Per-channel policy overrides |
---
## Dev commands
```bash
make check # TypeScript check (plugin/)
make check-rules # Validate rule-case fixtures
make check-files # Verify required files exist
make smoke # Smoke-test no-reply endpoint (sidecar must be running)
make install # Install plugin + sidecar into OpenClaw
```

281
TEST-PLAN.md Normal file
View File

@@ -0,0 +1,281 @@
# Dirigent 插件测试计划
> 版本v0.3.x | 测试环境OpenClaw 2026.4.9(待升级)
---
## 测试架构说明
**参与者**
- `main``home-developer``test-ph0`:测试 agent
- `CT-Moderator`:主持人 bot发送/删除调度消息)
- `Proxy Bot`:模拟人类用户,所有测试中需要"人工发消息"的操作均通过 Proxy Bot 完成
**核心机制**
- `before_model_resolve`:轮次门控(非当前 speaker → NO_REPLY
- `agent_end`:推进下一轮次,含 tail-match 轮询确认消息已发送
- `message_received`:外部消息处理(唤醒休眠、中断 tail-match、已结束频道自动回复
---
## 一、前置检查
| # | 检查项 | 期望结果 |
|---|--------|---------|
| 1.1 | OpenClaw 升级到 2026.4.9gateway 正常启动 | 日志无报错 |
| 1.2 | Dirigent 插件 deploy`cp -r plugin/* ~/.openclaw/plugins/dirigent/` | 日志显示 `dirigent: plugin registered (v2)` |
| 1.3 | `dirigent-identity.json` 确认三个 agent 均已注册 | `main``home-developer``test-ph0` 均有 `agentId``discordUserId` 映射 |
| 1.4 | Proxy Bot 在目标 guild 内可见,有发消息权限 | 能在私有频道中发消息 |
---
## 二、Chat 模式报数测试2 个 agent
> **目的:** 验证 2-agent 轮次调度和休眠dormant机制
### 2.1 创建频道并发起报数
**步骤:**
1. 在 Discord 中创建一个新的私有文字频道,将 `main``home-developer` 的 bot 账号及 CT-Moderator 加入
2.`127.0.0.1:18789/dirigent` 控制页面中将该频道设置为 **chat** 模式
3. Proxy Bot 在频道中发送指令:
> 请报数,从 0 开始,每次回复你看到的最后一个数字 +1。超过 5 之后,所有人只能回复 `NO_REPLY`。
**期望(调度行为):**
- Proxy Bot 消息触发 `message_received` → speaker list 初始化
- CT-Moderator 发送 `<@discordUserId>➡️` 后立即删除
- `main` 先发言回复 `1`CT-Moderator 触发 `home-developer`
- `home-developer` 回复 `2`CT-Moderator 再次触发 `main`
- 如此交替,直到某 agent 回复 `6`(超过 5
**期望(休眠行为):**
- 两个 agent 均回复 `NO_REPLY` 后,日志显示 `entered dormant`
- Chat 模式:**不发送**空闲提醒
- 频道无任何后续消息
**期望聊天记录:**
```
Proxy Bot: 请报数,从 0 开始 ...
main: 1
home-developer: 2
main: 3
home-developer: 4
main: 5
home-developer: 6
main: NO_REPLY (静默,无 Discord 消息)
home-developer: NO_REPLY (静默,无 Discord 消息)
← 进入休眠,频道沉默
```
### 2.2 唤醒休眠并验证 Moderator 不触发再次唤醒
**前置:** 频道处于休眠
**步骤:**
1. Proxy Bot 在频道发任意消息
**期望:**
- 日志显示 `woke dormant channel`
- CT-Moderator 发调度消息触发第一个 speaker轮次恢复
- CT-Moderator 自身的调度消息**不触发**二次唤醒(日志无第二条 `woke dormant`
---
## 三、Chat 模式报数测试3 个 agent验证 Shuffle
> **目的:** 验证 3-agent shuffle 模式下轮次顺序在每个 cycle 结束后随机重排
### 3.1 创建频道并发起报数
**步骤:**
1. 在 Discord 中创建一个新的私有文字频道,将 `main``home-developer``test-ph0` 的 bot 账号及 CT-Moderator 加入
2.`127.0.0.1:18789/dirigent` 控制页面中将该频道设置为 **chat** 模式
3. Proxy Bot 在频道中发送指令:
> 请报数,从 0 开始,每次回复你看到的最后一个数字 +1。超过 7 之后,所有人只能回复 `NO_REPLY`。
**期望(调度行为):**
- 三个 agent 依次发言Cycle 1 顺序由初始化决定)
- 每轮 3 条消息为一个 cycle
- **Cycle 1 结束后**,下一个 cycle 的顺序应与上一个不同shuffle 生效)
- Shuffle 约束:上一个 cycle 的最后一个 speaker 不能成为下一个 cycle 的第一个 speaker
**验证 Shuffle**
- 观察 Discord 聊天记录,记录每 3 条消息的发言者顺序
- 至少经历 2 个完整 cycle确认顺序发生变化
- 日志中确认 3-agent 场景走 shuffle 分支
**期望(休眠行为):**
- 超过 7 后三个 agent 均 `NO_REPLY` → 日志显示 `entered dormant`
**期望聊天记录示例顺序仅供参考shuffle 后会不同):**
```
Proxy Bot: 请报数,从 0 开始 ...
[Cycle 1]
main: 1
home-developer: 2
test-ph0: 3
[Cycle 2 — shuffle 后顺序可能变化]
test-ph0: 4
main: 5
home-developer: 6
[Cycle 3]
home-developer: 7
test-ph0: 8 (超过 7
main: NO_REPLY
...
← 进入休眠
```
### 3.2 外部消息中断 tail-match
**步骤:**
1. 在某 agent 完成发言、tail-match 轮询期间(约 0-15s 内)
2. Proxy Bot 立即发一条消息
**期望:**
- 日志显示 `tail-match interrupted`
- 轮次正常推进至下一个 speaker不卡住
---
## 四、Discussion 模式测试(完整生命周期)
> **目的:** 验证 discussion 频道从创建到结束的全流程,包括 callback
### 4.1 创建 Channel AChat并通过 agent 发起讨论
**步骤:**
1. 在 Discord 中创建一个新的私有文字频道(即 **Channel A**),将 `main` 的 bot 账号及 CT-Moderator 加入
2.`127.0.0.1:18789/dirigent` 控制页面中将 Channel A 设置为 **chat** 模式
3. Proxy Bot 在 Channel A 发送指令:
> 请使用 `create-discussion-channel` 工具,邀请 `home-developer` 参与一个讨论,主题自定,讨论结束条件是达成至少 2 条共识。
**期望:**
- `main` 调用 `create-discussion-channel` 工具Discord 中出现新私有 **Discussion 频道**
- `dirigent-channels.json` 新增该频道记录mode=discussionconcluded=falseinitiatorAgentId=maincallbackChannelId=Channel A 的 ID
- CT-Moderator 在 Discussion 频道发送讨论指南discussionGuide
- CT-Moderator 发调度消息触发第一个 speaker
### 4.2 Discussion 轮次正常运转
**期望:**
- `main``home-developer` 在 Discussion 频道内交替发言
- 非当前 speaker 静默(`before_model_resolve` 返回 NO_REPLY
### 4.3 Discussion 休眠 → 空闲提醒
**触发条件:** 两个 agent 在同一 cycle 内均输出 NO_REPLY
**期望discussion 独有行为):**
- 日志显示 `entered dormant`
- CT-Moderator 在 Discussion 频道发送空闲提醒给 **initiator**main
```
<@main的discordUserId> Discussion is idle. Please summarize the results and call `discussion-complete`.
```
- 只发一次
### 4.4 `discussion-complete` 结束讨论 → Callback 验证
**步骤:** `main` 在 Discussion 频道中调用 `discussion-complete` 工具
**期望:**
- `dirigent-channels.json` 中该频道 `concluded` 变为 `true`
- CT-Moderator 在 **Channel A**callbackChannel发送
```
Discussion complete. Summary: /path/...
```
- Discussion 频道不再有任何 agent 发言
### 4.5 已结束 Discussion 频道:外部消息自动回复(单次,无循环)
**步骤:** Proxy Bot 在已结束的 Discussion 频道发一条消息
**期望:**
- CT-Moderator 回复**恰好一次**`This discussion is closed and no longer active.`
- CT-Moderator 自己的这条回复**不触发**新的 "closed" 回复(无限循环修复验证)
- 日志确认senderId 匹配 moderatorBotUserId → 跳过 concluded auto-reply
---
## 五、Report / Work 模式测试
### 5.1 创建 Report 频道
**操作:** 让任意 agent 调用 `create-report-channel`
**期望:**
- 频道创建成功
- Proxy Bot 在该频道发消息后agent 不响应mode=report → NO_REPLY
### 5.2 创建 Work 频道
**操作:** 让任意 agent 调用 `create-work-channel`
**期望:**
- 频道创建成功mode=worklocked
- 无轮次管理agent 自由响应
### 5.3 Locked Mode 不可更改
**操作:** 对 discussion/work 频道调用 `/set-channel-mode`
**期望:**
- 报错:`Channel is in locked mode`
---
## 六、边界条件 & 回归验证
| # | 场景 | 期望 |
|---|------|------|
| 6.1 | Gateway 重启后chat 频道收到 Proxy Bot 消息 | 重新初始化 speaker list轮次正常恢复 |
| 6.2 | Proxy Bot 连续快速发多条消息(压力测试) | blocked-pending 计数不超过 MAX=3不形成死循环 |
| 6.3 | 同一事件被多个 VM 上下文处理 | globalThis dedupBMR WeakSet / agent_end Set / concluded Set确保只执行一次 |
| 6.4 | `fetchVisibleChannelBotAccountIds` 返回空列表 | 不崩溃,日志警告,不发调度消息 |
---
## 七、日志关键词速查
正常流程应出现的日志:
```
dirigent: plugin registered (v2)
dirigent: initialized speaker list channel=... speakers=...
dirigent: before_model_resolve anchor set channel=...
dirigent: triggered next speaker agentId=...
dirigent: agent_end channel=... empty=false
dirigent: entered dormant
dirigent: woke dormant channel=...
dirigent: moderator message sent to channel=...
```
异常(需关注):
```
dirigent: tail-match timeout ← 15s 内消息未落地
dirigent: agent_end skipping stale ← 正常stale NO_REPLY 被过滤)
dirigent: before_model_resolve init in progress ← 并发初始化保护(正常)
```
---
## 八、测试顺序建议
```
前置检查 (§1)
2-agent 报数:轮次 + 休眠 + 唤醒 (§2)
3-agent 报数shuffle 验证 + tail-match 中断 (§3)
Discussion 完整生命周期:创建 → 轮次 → 空闲提醒 → 结束 → callback → 防循环 (§4)
Report/Work 频道 (§5)
边界条件 (§6)
```
---
*测试中如遇 agent 卡住超过 10 分钟,重启 gateway 后继续。偶发的 5-10 分钟响应延迟属正常Kimi 模型特性)。*

51
achieve/CHANGELOG.md Normal file
View File

@@ -0,0 +1,51 @@
# Changelog
## 0.3.0
- **Split `dirigent_tools` into 6 individual tools**:
- Discord: `dirigent_discord_channel_create`, `dirigent_discord_channel_update`, `dirigent_discord_member_list`
- Policy: `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete`
- Turn management tools removed (internal plugin logic only; use `/dirigent` slash commands)
- **Human @mention override**: When a `humanList` user @mentions specific agents:
- Temporarily overrides the speaking order to only mentioned agents
- Agents cycle in their original turn-order position
- After all mentioned agents have spoken, original order restores and state goes dormant
- Handles edge cases: all NO_REPLY, reset, non-agent mentions
- **Wait for human reply**: New `waitIdentifier` (default: `👤`):
- Agent ends with `👤` to signal it needs a human response
- All agents go silent (routed to no-reply model) until a human speaks
- Prompt injection warns agents to use sparingly (only when human is actively participating)
- **Installer improvements**:
- Dynamic OpenClaw dir resolution (`$OPENCLAW_DIR``openclaw` CLI → `~/.openclaw`)
- Plugin installed to `$(openclaw_dir)/plugins/dirigent`
- New `--update` mode: pulls latest from git `latest` branch and reinstalls
## 0.2.0
- **Project renamed from WhisperGate to Dirigent**
- All plugin ids, tool names, config keys, file paths, docs updated
- Legacy `whispergate` config key still supported as fallback
- **Identity prompt enhancements**: Discord userId now included in agent identity injection
- **Scheduling identifier**: Added configurable `schedulingIdentifier` (default: `➡️`)
- Moderator handoff now sends `<@USER_ID>➡️` instead of semantic messages
- Agent prompt explains the identifier is meaningless — check chat history and decide
- **All prompts in English**: End-marker instructions, group chat rules, slash command help text
## 0.1.0-mvp
- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
- Added optional bearer auth (`AUTH_TOKEN`)
- Added plugin with deterministic rule gate
- Added discord-specific 🔚 prompt injection for bypass/end-symbol paths
- Added containerization (`Dockerfile`, `docker-compose.yml`)
- Added helper scripts for smoke/dev lifecycle and rule validation
- Added no-touch config rendering and integration docs
- Added installer script with rollback (`scripts/install-dirigent-openclaw.sh`)
- supports `--install` / `--uninstall`
- uninstall restores all recorded changes
- writes install/uninstall records under `~/.openclaw/dirigent-install-records/`
- Added discord-control-api with: (historical; later migrated into plugin internal Discord REST control)
- `channel-private-create` (create private channel for allowlist)
- `channel-private-update` (update allowlist/overwrites for existing channel)
- `member-list` (guild members list with pagination + optional field projection)
- guardrails: action mode validation, id-list limits, response-size limit

158
achieve/FEAT.md Normal file
View File

@@ -0,0 +1,158 @@
# Dirigent — Feature List
All implemented features across all versions.
---
## Core: Rule-Based No-Reply Gate
- Deterministic logic in `before_model_resolve` hook decides whether to route to no-reply model
- **human-list** mode: humanList senders bypass gate; others need end symbol to pass
- **agent-list** mode: agentList senders need end symbol; others bypass
- Non-Discord messages skip entirely
- DM sessions (no metadata) always bypass
- Per-channel policy overrides via JSON file (runtime-updateable)
## Core: End-Symbol Enforcement
- Injects prompt instruction: "Your response MUST end with 🔚"
- Gateway keywords (NO_REPLY, HEARTBEAT_OK) exempt from end symbol
- Group chats get additional rule: "If not relevant, reply NO_REPLY"
- End symbols configurable per-channel via policy
## Core: No-Reply API
- OpenAI-compatible API (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
- Always returns `NO_REPLY` — used as the override model target
- Optional bearer auth (`AUTH_TOKEN`)
- Auto-started/stopped with gateway lifecycle
## Turn-Based Speaking (Multi-Bot)
- Only the current speaker is allowed to respond; others forced to no-reply model
- Turn order auto-populated from bot accounts seen in each channel
- Turn advances on end-symbol (successful speech) or NO_REPLY
- Successful speech resets NO_REPLY cycle counter
- If all bots NO_REPLY in a cycle → channel goes **dormant**
- Dormant reactivation: human message → first in order; bot message → next after sender
- Turn timeout: auto-advance after 60s of inactivity
- Manual control: `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset`
## Human @Mention Override *(v0.3.0)*
- When a `humanList` user @mentions specific agents (`<@USER_ID>`):
- Extracts mentioned Discord user IDs from message content
- Maps userIds → accountIds via reverse bot token lookup
- Filters to agents in the current turn order
- Orders by their position in the current turn order
- Temporarily replaces speaking order with only those agents
- Cycle: a → b → c → (back to a) → restore original order, go dormant
- Edge cases:
- All override agents NO_REPLY → restore + dormant
- `resetTurn` clears any active override
- Human message without mentions → restores override, normal flow
- Mentioned users not in turn order → ignored
## Agent Identity Injection
- Group chat prompts include: agent name, Discord accountId, Discord userId
- userId resolved from bot token (base64 first segment)
## Wait for Human Reply *(v0.3.0)*
- Configurable wait identifier (default: `👤`)
- Agent ends message with `👤` instead of `🔚` when it needs a human to reply
- Triggers "waiting for human" state:
- All agents routed to no-reply model
- Turn manager goes dormant
- State clears automatically when a human sends a message
- Prompt injection tells agents:
- Use wait identifier only when confident the human is actively participating
- Do NOT use it speculatively
- Works with mention override: wait identifier during override also triggers waiting state
## Scheduling Identifier
- Configurable identifier (default: `➡️`) used for moderator handoff
- Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic scheduling signal)
- Agent prompt explains: identifier is meaningless — check chat history, decide whether to reply
- If nothing to say → NO_REPLY
## Moderator Bot Presence
- Maintains Discord Gateway WebSocket connection for moderator bot
- Shows "online" status with "Moderating" activity
- Handles reconnect, resume, heartbeat, invalid session recovery
- Singleton guard prevents duplicate connections
- Sends handoff messages to trigger next speaker's turn
## Individual Tools *(v0.3.0)*
Six standalone tools (split from former monolithic `dirigent_tools`):
### Discord Control
- **`dirigent_discord_channel_create`** — Create private Discord channel with user/role permissions
- **`dirigent_discord_channel_update`** — Update permissions on existing private channel
- **`dirigent_discord_member_list`** — List guild members with pagination and field projection
### Policy Management
- **`dirigent_policy_get`** — Get all channel policies
- **`dirigent_policy_set`** — Set/update a channel policy (listMode, humanList, agentList, endSymbols)
- **`dirigent_policy_delete`** — Delete a channel policy
### Turn Management (internal only — not exposed as tools)
Turn management is handled entirely by the plugin. Manual control via slash commands:
- `/dirigent turn-status` — Show turn state
- `/dirigent turn-advance` — Manually advance to next speaker
- `/dirigent turn-reset` — Reset turn order (go dormant, clear overrides)
## Slash Command: `/dirigent`
- `status` — Show all channel policies
- `turn-status` — Show turn state for current channel
- `turn-advance` — Manually advance turn
- `turn-reset` — Reset turn order
## Project Rename (WhisperGate → Dirigent) *(v0.2.0)*
- All plugin ids, tool names, config keys, file paths, docs updated
- Legacy `whispergate` config key still supported as fallback
## Installer Script *(updated v0.3.0)*
- `scripts/install-dirigent-openclaw.mjs`
- `--install` / `--uninstall` / `--update` modes
- **Dynamic OpenClaw dir resolution**: `$OPENCLAW_DIR``openclaw config get dataDir``~/.openclaw`
- Builds dist and copies to `$(openclaw_dir)/plugins/dirigent`
- `--update`: pulls latest from git `latest` branch, then reinstalls
- Auto-reinstall (uninstall + install) if already installed
- Backup before changes, rollback on failure
- Records stored in `$(openclaw_dir)/dirigent-install-records/`
## Discord Control API (Sidecar)
- Private channel create/update with permission overwrites
- Member list with pagination + field projection
- Guardrails: action validation, id-list limits, response-size limit
- (Migrated) Discord control now runs in-plugin via direct Discord REST (no companion service)
---
## NEW_FEAT 合并记录(原 NEW_FEAT.md
### 背景与目标
- 解决 turn 初始化依赖被动观察(`recordChannelAccount`)导致 `currentSpeaker` 空值的问题。
- 将 Discord control 从 sidecar 迁移到插件内模块。
- 采用 channel 成员缓存(内存 + 本地持久化),避免轮询。
### 关键实现方向
- 统一 channelId 解析链路,避免 `channel=discord` 错位。
- `before_model_resolve / before_prompt_build` 与消息 hook 使用一致解析策略。
- 清理未使用函数,降低排障噪音。
- 模块化重构:`index.ts` 作为 wiring逻辑拆入 `hooks/core/tools/policy/commands`
### Channel 成员缓存
- 缓存文件:`~/.openclaw/dirigent-channel-members.json`
- 启动加载、运行时原子写盘。
- 记录字段包含 `botAccountIds/updatedAt/source/guildId`
- 首次无缓存时允许 bootstrap 拉取,随后走本地缓存。
### Turn 初始化改造
- `ensureTurnOrder(channelId)` 基于缓存中的 botAccountIds 初始化。
- 不再仅依赖“已见账号”被动记录。
- 提升新频道首条消息场景的稳定性。
### 权限计算(频道可见成员)
- 通过 guild 成员 + roles + channel overwrites 计算 `VIEW_CHANNEL` 可见性。
- 用于内部 turn bootstrap不对外暴露为公共工具。
### 风险与注意
- 权限位计算必须严格按 Discord 规则。
- 缓存读写需原子化,防并发损坏。
- 通过 `updatedAt/source/guildId` 提高可观测性与排障效率。

86
achieve/TASKLIST.md Normal file
View File

@@ -0,0 +1,86 @@
# Dirigent Fixes & Improvements
> Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs).
## 1) Identity Prompt Enhancements ✅
- Current prompt only includes agent-id + discord name.
- **Add Discord userId** to identity injection.
- **Done**: `buildAgentIdentity()` now resolves and includes Discord userId via `resolveDiscordUserId()`.
## 2) Scheduling Identifier (Default: ➡️) ✅
- Add a **configurable scheduling identifier** (default: `➡️`).
- Update agent prompt to explain:
- The scheduling identifier itself is meaningless.
- When receiving `<@USER_ID>` + scheduling identifier, the agent should check chat history and decide whether to reply.
- If no reply needed, return `NO_REPLY`.
- **Done**: Added `schedulingIdentifier` config field; `buildSchedulingIdentifierInstruction()` injected for group chats.
## 3) Moderator Handoff Message Format ✅
- Moderator should **no longer send semantic messages** to activate agents.
- Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`).
- **Done**: Both `before_message_write` and `message_sent` handoff messages now use `<@userId>` + scheduling identifier format.
## 4) Prompt Language ✅
- **All prompts must be in English** (including end-marker instructions and group-chat rules).
- **Done**: `buildEndMarkerInstruction()` and `buildSchedulingIdentifierInstruction()` output English. Slash command help text in English.
## 5) Full Project Rename ✅
- Project name changed to **Dirigent**.
- Update **all strings** across repo:
- plugin name/id → `dirigent`
- tool name → `dirigent_tools`
- slash command → `/dirigent`
- docs, config, scripts, examples
- any text mentions
- dist output dir → `dist/dirigent`
- docker service → `dirigent-no-reply-api`
- config key fallback: still reads legacy `whispergate` entry if `dirigent` not found
- **Done**: All files updated.
---
## 6) Split dirigent_tools into Individual Tools ✅
- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions.
- **After**: 6 individual tools (Discord tools prefixed `dirigent_discord_*`):
- `dirigent_discord_channel_create` — Create private Discord channel
- `dirigent_discord_channel_update` — Update channel permissions
- `dirigent_discord_member_list` — List guild members
- `dirigent_policy_get` — Get all channel policies
- `dirigent_policy_set` — Set/update a channel policy
- `dirigent_policy_delete` — Delete a channel policy
- Turn management (status/advance/reset) NOT exposed as tools — purely internal plugin logic, accessible via `/dirigent` slash commands.
- Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication.
- **Done**: All tools registered individually with specific parameter schemas.
## 7) Human @Mention Override ✅
- When a message from a `humanList` user contains `<@USER_ID>` mentions:
- Extract mentioned Discord user IDs from the message content.
- Map user IDs → accountIds via bot token decoding (reverse of `resolveDiscordUserId`).
- Filter to agents in the current turn order.
- Order by their position in the current turn order.
- Temporarily replace the speaking order with only those agents.
- After the cycle returns to the first agent, restore the original order and go dormant.
- Edge cases handled:
- All override agents NO_REPLY → restore original order, go dormant.
- `resetTurn` clears any active override.
- New human message without mentions → restores override before normal handling.
- Mentioned users not in turn order → ignored, normal flow.
- New functions in `turn-manager.ts`: `setMentionOverride()`, `hasMentionOverride()`.
- New helpers in `index.ts`: `buildUserIdToAccountIdMap()`, `extractMentionedUserIds()`.
- **Done**: Override logic integrated in `message_received` handler.
## 8) Wait for Human Reply ✅
- Added configurable `waitIdentifier` (default: `👤`) to config and config schema.
- Prompt injection in group chats: tells agents to end with `👤` instead of end symbol when they need a human response. Warns to use sparingly — only when human is actively participating.
- Detection in `before_message_write` and `message_sent`: if last char matches wait identifier → `setWaitingForHuman(channelId)`.
- Turn manager `waitingForHuman` state:
- `checkTurn()` blocks all agents (`reason: "waiting_for_human"`) → routed to no-reply model.
- `onNewMessage()` clears `waitingForHuman` on human message → normal flow resumes.
- Non-human messages ignored while waiting.
- `resetTurn()` also clears waiting state.
- **Done**: Full lifecycle implemented across turn-manager, rules, and index.
---
## Open Items / Notes
- User requested the previous README commit should have been pushed to `main` directly (was pushed to a branch). Address separately if needed.

View File

@@ -1,9 +0,0 @@
{
"name": "whispergate-discord-control-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}

View File

@@ -1,388 +0,0 @@
import http from "node:http";
const port = Number(process.env.PORT || 8790);
const authToken = process.env.AUTH_TOKEN || "";
const requireAuthToken = String(process.env.REQUIRE_AUTH_TOKEN || "false").toLowerCase() === "true";
const discordToken = process.env.DISCORD_BOT_TOKEN || "";
const discordBase = "https://discord.com/api/v10";
const enabledActions = {
channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false",
channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false",
memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false",
};
const allowedGuildIds = new Set(
String(process.env.ALLOWED_GUILD_IDS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
const allowedCallerIds = new Set(
String(process.env.ALLOWED_CALLER_IDS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
const BIT_VIEW_CHANNEL = 1024n;
const BIT_SEND_MESSAGES = 2048n;
const BIT_READ_MESSAGE_HISTORY = 65536n;
const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20));
const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000));
const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200));
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function fail(status, code, message, details) {
return { status, code, message, details };
}
function readCallerId(req) {
const v = req.headers["x-openclaw-caller-id"] || req.headers["x-caller-id"];
return typeof v === "string" ? v.trim() : "";
}
function ensureControlAuth(req) {
if (requireAuthToken && !authToken) {
throw fail(500, "auth_misconfigured", "REQUIRE_AUTH_TOKEN=true but AUTH_TOKEN is empty");
}
if (!authToken) return;
const header = req.headers.authorization || "";
if (header !== `Bearer ${authToken}`) {
throw fail(401, "unauthorized", "invalid or missing bearer token");
}
if (allowedCallerIds.size > 0) {
const callerId = readCallerId(req);
if (!callerId || !allowedCallerIds.has(callerId)) {
throw fail(403, "caller_forbidden", "caller is not in ALLOWED_CALLER_IDS", { callerId: callerId || null });
}
}
}
function ensureDiscordToken() {
if (!discordToken) {
throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN");
}
}
function ensureGuildAllowed(guildId) {
if (allowedGuildIds.size === 0) return;
if (!allowedGuildIds.has(guildId)) {
throw fail(403, "guild_forbidden", "guild is not in ALLOWED_GUILD_IDS", { guildId });
}
}
function ensureActionEnabled(action) {
if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) {
throw fail(403, "action_disabled", "channel-private-create is disabled");
}
if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) {
throw fail(403, "action_disabled", "channel-private-update is disabled");
}
if (action === "member-list" && !enabledActions.memberList) {
throw fail(403, "action_disabled", "member-list is disabled");
}
}
async function discordRequest(path, init = {}) {
ensureDiscordToken();
const headers = {
Authorization: `Bot ${discordToken}`,
"Content-Type": "application/json",
...(init.headers || {}),
};
const r = await fetch(`${discordBase}${path}`, { ...init, headers });
const text = await r.text();
let data = text;
try {
data = text ? JSON.parse(text) : {};
} catch {}
if (!r.ok) {
throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data);
}
return data;
}
function toStringMask(v, fallback) {
if (v === undefined || v === null || v === "") return String(fallback);
if (typeof v === "string") return v;
if (typeof v === "number") return String(Math.floor(v));
if (typeof v === "bigint") return String(v);
throw fail(400, "invalid_mask", "invalid permission bit mask");
}
function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) {
const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY;
const denyDefault = BIT_VIEW_CHANNEL;
const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault);
const targetAllow = toStringMask(allowMask, allowDefault);
const overwrites = [
{
id: guildId,
type: 0,
allow: "0",
deny: everyoneDeny,
},
];
for (const roleId of allowedRoleIds) {
overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" });
}
for (const userId of allowedUserIds) {
overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" });
}
return overwrites;
}
function parseFieldList(input) {
if (Array.isArray(input)) return input.map((x) => String(x).trim()).filter(Boolean);
if (typeof input === "string") return input.split(",").map((x) => x.trim()).filter(Boolean);
return [];
}
function normalizeIdList(value, label) {
const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : [];
if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) {
throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, {
label,
limit: MAX_PRIVATE_MUTATION_TARGETS,
size: arr.length,
});
}
return arr;
}
function pick(obj, keys) {
if (!obj || typeof obj !== "object") return obj;
const out = {};
for (const k of keys) {
if (k in obj) out[k] = obj[k];
}
return out;
}
function projectMember(member, fields) {
if (!fields.length) return member;
const base = pick(member, fields);
if (fields.some((f) => f.startsWith("user.")) && member?.user) {
const userFields = fields
.filter((f) => f.startsWith("user."))
.map((f) => f.slice(5))
.filter(Boolean);
base.user = pick(member.user, userFields);
}
return base;
}
async function actionChannelPrivateCreate(body) {
const guildId = String(body.guildId || "").trim();
const name = String(body.name || "").trim();
if (!guildId) throw fail(400, "bad_request", "guildId is required");
if (!name) throw fail(400, "bad_request", "name is required");
ensureGuildAllowed(guildId);
const payload = {
name,
type: Number.isInteger(body.type) ? body.type : 0,
parent_id: body.parentId || undefined,
topic: body.topic || undefined,
position: Number.isInteger(body.position) ? body.position : undefined,
nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined,
permission_overwrites: buildPrivateOverwrites({
guildId,
allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"),
allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"),
allowMask: body.allowMask,
denyEveryoneMask: body.denyEveryoneMask,
}),
};
if (body.dryRun === true) {
return { ok: true, action: "channel-private-create", dryRun: true, payload };
}
const channel = await discordRequest(`/guilds/${guildId}/channels`, {
method: "POST",
body: JSON.stringify(payload),
});
return { ok: true, action: "channel-private-create", channel };
}
async function actionChannelPrivateUpdate(body) {
const guildId = String(body.guildId || "").trim();
const channelId = String(body.channelId || "").trim();
if (!guildId) throw fail(400, "bad_request", "guildId is required");
if (!channelId) throw fail(400, "bad_request", "channelId is required");
ensureGuildAllowed(guildId);
const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY);
const denyMask = toStringMask(body.denyMask, 0n);
const mode = String(body.mode || "merge").trim();
if (mode !== "merge" && mode !== "replace") {
throw fail(400, "bad_request", "mode must be merge or replace", { mode });
}
const addUserIds = normalizeIdList(body.addUserIds, "addUserIds");
const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds");
const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds");
const existing = await discordRequest(`/channels/${channelId}`);
const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : [];
let next = [];
if (mode === "replace") {
// keep @everyone deny if present, otherwise set one
const everyone = existingOverwrites.find((o) => String(o.id) === guildId && Number(o.type) === 0) || {
id: guildId,
type: 0,
allow: "0",
deny: String(BIT_VIEW_CHANNEL),
};
next.push({ id: String(everyone.id), type: 0, allow: String(everyone.allow || "0"), deny: String(everyone.deny || BIT_VIEW_CHANNEL) });
} else {
next = existingOverwrites.map((o) => ({ id: String(o.id), type: Number(o.type) === 1 ? 1 : 0, allow: String(o.allow || "0"), deny: String(o.deny || "0") }));
}
const removeSet = new Set(removeTargetIds);
if (removeSet.size > 0) {
next = next.filter((o) => !removeSet.has(String(o.id)));
}
const upsert = (id, type) => {
const idx = next.findIndex((o) => String(o.id) === String(id));
const row = { id: String(id), type, allow: allowMask, deny: denyMask };
if (idx >= 0) next[idx] = row;
else next.push(row);
};
for (const userId of addUserIds) upsert(userId, 1);
for (const roleId of addRoleIds) upsert(roleId, 0);
const payload = { permission_overwrites: next };
if (body.dryRun === true) {
return { ok: true, action: "channel-private-update", dryRun: true, payload, mode };
}
const channel = await discordRequest(`/channels/${channelId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
return { ok: true, action: "channel-private-update", mode, channel };
}
async function actionMemberList(body) {
const guildId = String(body.guildId || "").trim();
if (!guildId) throw fail(400, "bad_request", "guildId is required");
ensureGuildAllowed(guildId);
const limitRaw = Number(body.limit ?? 100);
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
const after = body.after ? String(body.after) : undefined;
const fields = parseFieldList(body.fields);
if (fields.length > MAX_MEMBER_FIELDS) {
throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", {
limit: MAX_MEMBER_FIELDS,
size: fields.length,
});
}
const qs = new URLSearchParams();
qs.set("limit", String(limit));
if (after) qs.set("after", after);
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members;
const response = {
ok: true,
action: "member-list",
guildId,
count: Array.isArray(projected) ? projected.length : 0,
fields: fields.length ? fields : undefined,
members: projected,
};
const bytes = Buffer.byteLength(JSON.stringify(response), "utf8");
if (bytes > MAX_MEMBER_RESPONSE_BYTES) {
throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", {
bytes,
limit: MAX_MEMBER_RESPONSE_BYTES,
hint: "reduce limit or set fields projection",
});
}
return response;
}
async function handleAction(body) {
const action = String(body.action || "").trim();
if (!action) throw fail(400, "bad_request", "action is required");
ensureActionEnabled(action);
if (action === "channel-private-create") return await actionChannelPrivateCreate(body);
if (action === "channel-private-update") return await actionChannelPrivateUpdate(body);
if (action === "member-list") return await actionMemberList(body);
throw fail(400, "unsupported_action", `unsupported action: ${action}`);
}
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
return sendJson(res, 200, {
ok: true,
service: "discord-control-api",
authRequired: !!authToken || requireAuthToken,
actionGates: enabledActions,
guildAllowlistEnabled: allowedGuildIds.size > 0,
limits: {
maxMemberFields: MAX_MEMBER_FIELDS,
maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES,
maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS,
},
});
}
if (req.method !== "POST" || req.url !== "/v1/discord/action") {
return sendJson(res, 404, { error: "not_found" });
}
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 2_000_000) req.destroy();
});
req.on("end", async () => {
try {
ensureControlAuth(req);
const parsed = body ? JSON.parse(body) : {};
const result = await handleAction(parsed);
return sendJson(res, 200, result);
} catch (err) {
return sendJson(res, err?.status || 500, {
error: err?.code || "request_failed",
message: String(err?.message || err),
details: err?.details,
});
}
});
});
server.listen(port, () => {
console.log(`[discord-control-api] listening on :${port}`);
});

View File

@@ -1,11 +0,0 @@
services:
whispergate-no-reply-api:
build:
context: ./no-reply-api
container_name: whispergate-no-reply-api
ports:
- "8787:8787"
environment:
- PORT=8787
- NO_REPLY_MODEL=whispergate-no-reply-v1
restart: unless-stopped

View File

@@ -1,10 +1,10 @@
{
"plugins": {
"load": {
"paths": ["/path/to/WhisperGate/dist/whispergate"]
"paths": ["/path/to/Dirigent/dist/dirigent"]
},
"entries": {
"whispergate": {
"dirigent": {
"enabled": true,
"config": {
"enabled": true,
@@ -13,14 +13,17 @@
"humanList": ["561921120408698910"],
"agentList": [],
"endSymbols": ["🔚"],
"channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json",
"noReplyProvider": "whisper-gateway",
"schedulingIdentifier": "➡️",
"multiMessageStartMarker": "↗️",
"multiMessageEndMarker": "↙️",
"multiMessagePromptMarker": "⤵️",
"channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json",
"noReplyProvider": "dirigentway",
"noReplyModel": "no-reply",
"enableDiscordControlTool": true,
"enableWhispergatePolicyTool": true,
"enableDirigentPolicyTool": true,
"enableDebugLogs": false,
"debugLogChannelIds": [],
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
"discordControlApiToken": "<DISCORD_CONTROL_AUTH_TOKEN>",
"discordControlCallerId": "agent-main"
}
@@ -29,7 +32,7 @@
},
"models": {
"providers": {
"whisper-gateway": {
"dirigentway": {
"apiKey": "<NO_REPLY_API_TOKEN_OR_PLACEHOLDER>",
"baseUrl": "http://127.0.0.1:8787/v1",
"api": "openai-completions",
@@ -52,7 +55,7 @@
{
"id": "main",
"tools": {
"allow": ["whispergate"]
"allow": ["dirigent"]
}
}
]

View File

@@ -1,150 +0,0 @@
# Discord Control API
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
> 现在可以通过 WhisperGate 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl
> 注意:该工具是 optional需要在 agent tools allowlist 中显式允许(例如允许 `whispergate` 或 `discord_control`)。
1. 创建指定名单可见的私人频道
2. 查看 server 成员列表(分页)
## Start
```bash
cd discord-control-api
export DISCORD_BOT_TOKEN='xxx'
# 建议启用
export AUTH_TOKEN='strong-token'
# optional hard requirement
# export REQUIRE_AUTH_TOKEN=true
# optional action gates
# export ENABLE_CHANNEL_PRIVATE_CREATE=true
# export ENABLE_CHANNEL_PRIVATE_UPDATE=true
# export ENABLE_MEMBER_LIST=true
# optional allowlist
# export ALLOWED_GUILD_IDS='123,456'
# export ALLOWED_CALLER_IDS='agent-main,agent-admin'
# optional limits
# export MAX_MEMBER_FIELDS=20
# export MAX_MEMBER_RESPONSE_BYTES=500000
# export MAX_PRIVATE_MUTATION_TARGETS=200
node server.mjs
```
Health:
```bash
curl -sS http://127.0.0.1:8790/health
```
## Unified action endpoint
`POST /v1/discord/action`
- Header: `Authorization: Bearer <AUTH_TOKEN>`(若配置)
- Header: `X-OpenClaw-Caller-Id: <id>`(若配置了 `ALLOWED_CALLER_IDS`
- Body: `{ "action": "...", ... }`
---
## Action: channel-private-create
与 OpenClaw `channel-create` 参数保持风格一致,并增加私密覆盖参数。
### Request
```json
{
"action": "channel-private-create",
"guildId": "123",
"name": "private-room",
"type": 0,
"parentId": "456",
"topic": "secret",
"position": 3,
"nsfw": false,
"allowedUserIds": ["111", "222"],
"allowedRoleIds": ["333"],
"allowMask": "67648",
"denyEveryoneMask": "1024",
"dryRun": false
}
```
说明:
- 默认 deny `@everyone``VIEW_CHANNEL`
- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`
- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。
---
## Action: channel-private-update
对现有频道的白名单/覆盖权限做增删改。
### Request
```json
{
"action": "channel-private-update",
"guildId": "123",
"channelId": "789",
"mode": "merge",
"addUserIds": ["111"],
"addRoleIds": ["333"],
"removeTargetIds": ["222"],
"allowMask": "67648",
"denyMask": "0",
"dryRun": false
}
```
说明:
- `mode=merge`:在现有覆盖基础上增删
- `mode=replace`:重建覆盖(保留/补上 @everyone deny
---
## Action: member-list
### Request
```json
{
"action": "member-list",
"guildId": "123",
"limit": 100,
"after": "0",
"fields": ["user.id", "user.username", "nick", "roles", "joined_at"]
}
```
说明:
- `limit` 1~1000
- `after` 用于分页Discord snowflake
- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段
---
## Curl examples
示例文件:`docs/EXAMPLES.discord-control.json`
快速检查脚本:
```bash
./scripts/smoke-discord-control.sh
# with env
# AUTH_TOKEN=xxx CALLER_ID=agent-main GUILD_ID=123 CHANNEL_ID=456 ./scripts/smoke-discord-control.sh
```
## Notes
鉴权与内置风格对齐(简化版):
- 控制面 token`AUTH_TOKEN` / `REQUIRE_AUTH_TOKEN`
- 调用者 allowlist`ALLOWED_CALLER_IDS`(配合 `X-OpenClaw-Caller-Id`
- action gate`ENABLE_CHANNEL_PRIVATE_CREATE` / `ENABLE_MEMBER_LIST`
- guild allowlist`ALLOWED_GUILD_IDS`
- 这不是 bot 自提权工具bot 仍需由管理员授予足够权限。
- 若无权限Discord API 会返回 403 并透传错误细节。

View File

@@ -1,8 +1,8 @@
# WhisperGate Implementation Notes
# Dirigent Implementation Notes
## Decision path
WhisperGate evaluates in strict order:
Dirigent evaluates in strict order:
1. channel check (discord-only)
2. bypass sender check

View File

@@ -1,4 +1,4 @@
# WhisperGate Integration (No-touch Template)
# Dirigent Integration (No-touch Template)
This guide **does not** change your current OpenClaw config automatically.
It only generates a JSON snippet you can review.
@@ -7,9 +7,9 @@ It only generates a JSON snippet you can review.
```bash
node scripts/render-openclaw-config.mjs \
/absolute/path/to/WhisperGate/plugin \
/absolute/path/to/Dirigent/plugin \
openai \
whispergate-no-reply-v1 \
dirigent-no-reply-v1 \
561921120408698910
```
@@ -23,29 +23,26 @@ Arguments:
The script prints JSON for:
- `plugins.load.paths`
- `plugins.entries.whispergate.config`
- `plugins.entries.dirigent.config`
You can merge this snippet manually into your `openclaw.json`.
## Installer script (with rollback)
For production-like install with automatic rollback on error (Node-only installer):
## Installer script
```bash
node ./scripts/install-whispergate-openclaw.mjs --install
node ./scripts/install.mjs --install
# optional port override
node ./scripts/install.mjs --install --no-reply-port 8787
# or wrapper
./scripts/install-whispergate-openclaw.sh --install
./scripts/install-dirigent-openclaw.sh --install
```
Uninstall (revert all recorded config changes):
Uninstall:
```bash
node ./scripts/install-whispergate-openclaw.mjs --uninstall
node ./scripts/install.mjs --uninstall
# or wrapper
./scripts/install-whispergate-openclaw.sh --uninstall
# or specify a record explicitly
# RECORD_FILE=~/.openclaw/whispergate-install-records/whispergate-YYYYmmddHHMMSS.json \
# node ./scripts/install-whispergate-openclaw.mjs --uninstall
./scripts/install-dirigent-openclaw.sh --uninstall
```
Environment overrides:
@@ -61,23 +58,26 @@ Environment overrides:
- `CHANNEL_POLICIES_FILE` (standalone channel policy file path)
- `CHANNEL_POLICIES_JSON` (only used to initialize file when missing)
- `END_SYMBOLS_JSON`
- `MULTI_MESSAGE_START_MARKER`
- `MULTI_MESSAGE_END_MARKER`
- `MULTI_MESSAGE_PROMPT_MARKER`
The script:
- writes via `openclaw config set ... --json`
- creates config backup first
- restores backup automatically if any install step fails
- restarts gateway during install, then validates `whisper-gateway/no-reply` is visible via `openclaw models list/status`
- writes a change record for every install/uninstall:
- directory: `~/.openclaw/whispergate-install-records/`
- latest pointer: `~/.openclaw/whispergate-install-record-latest.json`
- installs plugin + no-reply-api into `~/.openclaw/plugins`
- updates `plugins.entries.dirigent` and `models.providers.<no-reply-provider>`
- supports `--no-reply-port` (also written into `plugins.entries.dirigent.config.noReplyPort`)
- does not maintain install/uninstall record files
Policy state semantics:
- channel policy file is loaded once into memory on startup
- runtime decisions use in-memory state
- use `whispergate_policy` tool to update state (memory first, then file persist)
- use `dirigent_policy` tool to update state (memory first, then file persist)
- manual file edits do not auto-apply until next restart
## Notes
- Keep no-reply API bound to loopback/private network.
- If you use API auth, set `AUTH_TOKEN` and align provider apiKey usage.
- Multi-message mode markers default to `↗️` / `↙️` / `⤵️` when no overrides are supplied.
- Shuffle mode is not configured globally in the current implementation; it is a per-channel runtime toggle controlled with `/dirigent turn-shuffling`, `/dirigent turn-shuffling on`, and `/dirigent turn-shuffling off`.

View File

@@ -1,15 +1,15 @@
# PR Summary (WhisperGate + Discord Control)
# PR Summary (Dirigent + Discord Control)
## Scope
This PR delivers two tracks:
1. WhisperGate deterministic no-reply gate for Discord sessions
1. Dirigent deterministic no-reply gate for Discord sessions
2. Discord control extension API for private-channel/member-list gaps
## Delivered Features
### WhisperGate
### Dirigent
- Deterministic rule chain:
1) non-discord => skip
@@ -51,5 +51,5 @@ This PR delivers two tracks:
## Rollback
- Disable plugin entry or remove plugin path from OpenClaw config
- Stop `discord-control-api` process
- (Legacy note) `discord-control-api` sidecar has been removed; Discord control is in-plugin now
- Keep no-reply API stopped if not needed

View File

@@ -8,17 +8,17 @@ node scripts/package-plugin.mjs
Output:
- `dist/whispergate/index.ts`
- `dist/whispergate/rules.ts`
- `dist/whispergate/openclaw.plugin.json`
- `dist/whispergate/README.md`
- `dist/whispergate/package.json`
- `dist/dirigent/index.ts`
- `dist/dirigent/rules.ts`
- `dist/dirigent/openclaw.plugin.json`
- `dist/dirigent/README.md`
- `dist/dirigent/package.json`
## Use packaged plugin path
Point OpenClaw `plugins.load.paths` to:
`/absolute/path/to/WhisperGate/dist/whispergate`
`/absolute/path/to/Dirigent/dist/dirigent`
## Verify package completeness

View File

@@ -1,4 +1,4 @@
# WhisperGate Rollout Checklist
# Dirigent Rollout Checklist
## Stage 0: Local sanity
@@ -29,5 +29,5 @@
## Rollback
- Disable plugin entry `whispergate.enabled=false` OR remove plugin path
- Disable plugin entry `dirigent.enabled=false` OR remove plugin path
- Keep API service running; it is inert when plugin disabled

View File

@@ -1,6 +1,6 @@
# Run Modes
WhisperGate has two runtime components:
Dirigent has two runtime components:
1. `plugin/` (OpenClaw plugin)
2. `no-reply-api/` (deterministic NO_REPLY service)
@@ -20,7 +20,7 @@ Then configure OpenClaw provider `baseURL` to `http://127.0.0.1:8787/v1`.
```bash
./scripts/dev-up.sh
# or: docker compose up -d --build whispergate-no-reply-api
# or: docker compose up -d --build dirigent-no-reply-api
```
Stop:

View File

@@ -1,4 +1,4 @@
# WhisperGate 测试记录报告(阶段性)
# Dirigent 测试记录报告(阶段性)
日期2026-02-25
@@ -6,19 +6,19 @@
本轮覆盖:
1. WhisperGate 基础静态与脚本测试
1. Dirigent 基础静态与脚本测试
2. no-reply-api 隔离集成测试
3. discord-control-api 功能测试dryRun + 实操)
3. (历史)discord-control-api 功能测试dryRun + 实操,当前版本已迁移为 in-plugin
未覆盖:
- WhisperGate 插件真实挂载 OpenClaw 后的端到端E2E
- Dirigent 插件真实挂载 OpenClaw 后的端到端E2E
---
## 二、测试环境
- 代码仓库:`/root/.openclaw/workspace-operator/WhisperGate`
- 代码仓库:`/root/.openclaw/workspace-operator/Dirigent`
- OpenClaw 配置来源:本机已有配置(读取 Discord token
- Discord guildserverID`1368531017534537779`
- allowlist user IDs
@@ -29,7 +29,7 @@
## 三、已执行测试与结果
### A. WhisperGate 基础测试
### A. Dirigent 基础测试
命令:
@@ -53,7 +53,7 @@ make check check-rules test-api
---
### B. discord-control-api dryRun + 实操测试
### B. (历史)discord-control-api dryRun + 实操测试(当前版本已迁移)
执行内容与结果:
@@ -95,7 +95,7 @@ make check check-rules test-api
## 五、待测项(下一阶段)
### 1) WhisperGate 插件 E2E需临时接入 OpenClaw 配置)
### 1) Dirigent 插件 E2E需临时接入 OpenClaw 配置)
目标:验证插件真实挂载后的完整链路。
@@ -113,7 +113,7 @@ make check check-rules test-api
### 2) 回归测试
- discord-control-api 引入后,不影响 WhisperGate 原有流程
- (历史结论)discord-control-api 引入后,不影响 Dirigent 原有流程;现已迁移为 in-plugin 实现
- 规则校验脚本在最新代码继续稳定通过
### 3) 运行与安全校验
@@ -127,4 +127,4 @@ make check check-rules test-api
## 六、当前结论
- 新增 Discord 管控能力(私密频道 + 成员列表)已完成核心测试,可用。
- 项目剩余主要测试工作集中在 WhisperGate 插件与 OpenClaw 的真实 E2E 联调。
- 项目剩余主要测试工作集中在 Dirigent 插件与 OpenClaw 的真实 E2E 联调。

View File

@@ -2,7 +2,7 @@
## Context
WhisperGate implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override.
Dirigent implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override.
## The Problem
@@ -12,7 +12,7 @@ When the current speaker responds with **NO_REPLY** (decides the message is not
1. A message arrives in the Discord channel
2. OpenClaw routes it to **all** agent sessions in that channel simultaneously
3. The WhisperGate plugin intercepts at `before_model_resolve`:
3. The Dirigent plugin intercepts at `before_model_resolve`:
- Current speaker → allowed to process
- Everyone else → forced to no-reply model (message is "consumed" silently)
4. Current speaker processes the message and returns NO_REPLY
@@ -78,7 +78,7 @@ When current speaker NO_REPLYs, have **that bot** send a brief handoff message i
**Challenges:**
- Adds visible noise to the channel (could use a convention like a specific emoji reaction)
- The no-reply'd bot can't send messages (it was silenced)
- Could use the discord-control-api to send as a different bot
- Could use in-plugin Discord REST control to send as a different bot (sidecar removed)
### 6. Timer-Based Retry (Pragmatic)
@@ -93,7 +93,7 @@ After advancing the turn, set a short timer (e.g., 2-3 seconds). If no new messa
**Solution 5 (Bot-to-Bot Handoff)** is the most pragmatic with current constraints. The implementation would be:
1. In the `message_sent` hook, after detecting NO_REPLY and advancing the turn:
2. Use the discord-control-api to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
2. Use in-plugin Discord REST control to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
3. This real Discord message triggers OpenClaw to route it to all agents
4. The turn manager allows only the (now-current) next speaker to respond
5. The next speaker sees the original conversation context in their session history and responds appropriately

View File

@@ -1,4 +1,4 @@
# WhisperGate Quick Verification
# Dirigent Quick Verification
## 1) Start no-reply API
@@ -15,7 +15,7 @@ npm start
curl -sS http://127.0.0.1:8787/health
curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hi"}]}'
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hi"}]}'
```
Or run bundled smoke check:

View File

@@ -1,2 +0,0 @@
node_modules
npm-debug.log

View File

@@ -1,7 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY server.mjs ./
EXPOSE 8787
ENV PORT=8787
CMD ["node", "server.mjs"]

View File

@@ -1,12 +0,0 @@
{
"name": "whispergate-no-reply-api",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "whispergate-no-reply-api",
"version": "0.1.0"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"name": "whispergate-no-reply-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}

View File

@@ -1,112 +0,0 @@
import http from "node:http";
const port = Number(process.env.PORT || 8787);
const modelName = process.env.NO_REPLY_MODEL || "whispergate-no-reply-v1";
const authToken = process.env.AUTH_TOKEN || "";
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function isAuthorized(req) {
if (!authToken) return true;
const header = req.headers.authorization || "";
return header === `Bearer ${authToken}`;
}
function noReplyChatCompletion(reqBody) {
return {
id: `chatcmpl_whispergate_${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
choices: [
{
index: 0,
message: { role: "assistant", content: "NO_REPLY" },
finish_reason: "stop"
}
],
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 }
};
}
function noReplyResponses(reqBody) {
return {
id: `resp_whispergate_${Date.now()}`,
object: "response",
created_at: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "NO_REPLY" }]
}
],
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 }
};
}
function listModels() {
return {
object: "list",
data: [
{
id: modelName,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "whispergate"
}
]
};
}
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
return sendJson(res, 200, { ok: true, service: "whispergate-no-reply-api", model: modelName });
}
if (req.method === "GET" && req.url === "/v1/models") {
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
return sendJson(res, 200, listModels());
}
if (req.method !== "POST") {
return sendJson(res, 404, { error: "not_found" });
}
if (!isAuthorized(req)) {
return sendJson(res, 401, { error: "unauthorized" });
}
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) req.destroy();
});
req.on("end", () => {
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch {
return sendJson(res, 400, { error: "invalid_json" });
}
if (req.url === "/v1/chat/completions") {
return sendJson(res, 200, noReplyChatCompletion(parsed));
}
if (req.url === "/v1/responses") {
return sendJson(res, 200, noReplyResponses(parsed));
}
return sendJson(res, 404, { error: "not_found" });
});
});
server.listen(port, () => {
console.log(`[whispergate-no-reply-api] listening on :${port}`);
});

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "@hangman-lab/dirigent",
"version": "0.3.0",
"description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw",
"type": "module",
"files": [
"dist/",
"plugin/",
"services/",
"docs/",
"scripts/install.mjs",
"docker-compose.yml",
"Makefile",
"README.md",
"CHANGELOG.md",
"TASKLIST.md"
],
"scripts": {
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/ && cp -r services dist/dirigent/services",
"test": "node --test --experimental-strip-types test/**/*.test.ts",
"postinstall": "node scripts/install.mjs --install",
"uninstall": "node scripts/install.mjs --uninstall",
"update": "node scripts/install.mjs --update"
},
"keywords": [
"openclaw",
"plugin",
"discord",
"moderation",
"turn-management"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.hangman-lab.top/nav/Dirigent.git"
},
"engines": {
"node": ">=20"
}
}

View File

@@ -0,0 +1,249 @@
# Channel Modes & Turn Shuffle 规划
## 背景
`feat/new-feat-notes` 分支中的 `NEW_FEAT.md` 提出了两项新的行为增强需求,需要整合进入 `main` 分支下的 `plans/`
1. **Multi-Message Mode人类连续多消息模式**
2. **Shuffle Mode轮转顺序重洗牌**
这两项能力都与 Dirigent 当前的 turn-manager、moderator handoff、no-reply override 机制直接相关,因此应作为 turn orchestration 的功能扩展纳入规划,而不是孤立实现。
---
## 1. Multi-Message Mode
### 1.1 目标
允许人类在一个 channel 中连续发送多条消息而不被 Agent 打断。
当用户显式进入 multi-message mode 后:
- 当前 channel 的 turn manager 暂停
- 所有 Agent 在该 channel 中进入 no-reply 覆盖
- moderator bot 对每条人类追加消息发送 prompt marker提示继续输入
- 直到人类发送 end marker 才恢复 turn manager
- 恢复后由 moderator 按现有调度机制唤醒下一位 Agent
---
### 1.2 配置项
新增并已实现以下可配置项:
- `multiMessageStartMarker`:默认 `↗️`
- `multiMessageEndMarker`:默认 `↙️`
- `multiMessagePromptMarker`:默认 `⤵️`
这些配置已加入插件 config schema并在运行时被 `message-received` / `before-message-write` / `before-model-resolve` 使用。
---
### 1.3 行为规则
#### 进入 multi-message mode
**human** 消息中包含 start marker
- 当前 channel 进入 `multi-message` 状态
- turn manager 暂停
- 当前 channel 下所有 Agent 在该 channel/session 中走 no-reply override
#### multi-message mode 中
在该模式下:
- 人类每发一条消息
- moderator bot 自动回复 prompt marker例如 `⤵️`
- Agent 不应参与正常轮转回复
#### 退出 multi-message mode
**human** 消息中包含 end marker
- 当前 channel 退出 `multi-message` 状态
- turn manager 恢复
- moderator bot 发送当前调度标识scheduling identifier唤醒下一位 Agent
---
### 1.4 状态建议
每个 Discord channel 维护一个 channel mode
- `normal`
- `multi-message`
- (后续可扩展其他模式)
multi-message mode 应与 discussion channel / wait-for-human / no-reply 决策互相兼容,优先级需要明确。
建议优先级初稿:
1. closed discussion channel
2. multi-message mode
3. waiting-for-human
4. normal turn-manager
---
## 2. Shuffle Mode
### 2.1 目标
在 turn-based speaking 中,为每个 Discord channel 增加可选的 turn order reshuffle 能力。
当 shuffle mode 开启时:
- 在一轮 turn list 的最后一位 speaker 发言完成后
- 对 turn order 重新洗牌
- 但必须保证:**上一轮最后一位发言者不能在新一轮中成为第一位**
---
### 2.2 配置 / 控制方式
为每个 Discord channel 维护:
- `shuffling: boolean`
并新增 slash command
- `/dirigent turn-shuffling on`
- `/dirigent turn-shuffling off`
- `/dirigent turn-shuffling`(查看当前状态)
当前实现结论:
- `shuffling`**channel 级 runtime state**,存放在 `plugin/core/channel-modes.ts`
- 默认值为 `false`
- 当前版本**不新增**全局 `shuffle default` 配置项
- 重启后会恢复为默认关闭,如需开启需要再次执行命令
这样与现有实现保持一致,也避免把一次性的实验性调度偏好混入全局静态配置。
---
### 2.3 行为规则
`shuffling = true` 时:
- 正常按 turn order 轮转
- 当“当前 speaker 是该轮最后一位”并完成发言后
- 进入下一轮前重洗 turn order
- 新 turn order 必须满足:
- 上一轮最后 speaker != 新 turnOrder[0]
若无法满足(极端小集合场景),需要定义 fallback
- 两个 Agent 时可退化为简单交换/固定顺序
- 单 Agent 时不需要 shuffle
---
## 3. 与当前系统的关系
这两个能力都不是独立子系统,而是 Dirigent turn orchestration 的扩展。
### 涉及模块
- `plugin/turn-manager.ts`
- `plugin/hooks/message-received.ts`
- `plugin/hooks/before-model-resolve.ts`
- `plugin/hooks/before-message-write.ts`
- `plugin/hooks/message-sent.ts`
- `plugin/commands/dirigent-command.ts`
- `plugin/index.ts`
- `plugin/openclaw.plugin.json`
- 如有必要新增 `plugin/core/channel-modes.ts` 或类似 runtime state 模块
---
## 4. 建议实现方向
### 4.1 Multi-Message Mode
建议不要把 multi-message mode 直接塞进规则判断字符串里,而是做成显式 channel runtime state。
建议新增:
- channel mode state store
- helper
- `enterMultiMessageMode(channelId)`
- `exitMultiMessageMode(channelId)`
- `isMultiMessageMode(channelId)`
然后:
- `message-received` 检测 human 消息中的 start/end marker
- `before-model-resolve` 检测当前 channel 是否处于 multi-message mode若是则直接走 no-reply override
- `message-received` 或合适的消息链路中触发 moderator prompt marker
- 退出时触发一次 scheduling handoff
---
### 4.2 Shuffle Mode
建议将 shuffle 状态与 turn order 状态放在一起,或由 turn-manager 引用单独的 channel config/state。
建议新增 helper
- `setChannelShuffling(channelId, enabled)`
- `getChannelShuffling(channelId)`
- `reshuffleTurnOrder(channelId, { avoidFirstAccountId })`
并在 turn cycle 的边界点调用重洗逻辑,而不是在任意发言后随机改顺序。
---
## 5. 风险与边界
### Multi-Message Mode
- 需要防止 moderator prompt marker 自己再次触发 turn logic
- 需要明确 start/end marker 的匹配规则(全文包含、末尾匹配、独立一条指令等)
- 需要明确 multi-message mode 下 human @mention 是否还有特殊行为
- 需要避免与 discussion closed / waiting-for-human 冲突
### Shuffle Mode
- 两个 Agent 的洗牌约束需要单独处理
- turn order 若正处于 mention override / waiting-for-human / dormant需要明确何时允许 reshuffle
- 若 turn order 成员变化shuffle 与 bootstrap 的先后顺序要明确
---
## 6. 实现状态
Multi-Message Mode 与 Shuffle Mode 已经在代码中实现,包括:
- Multi-Message Mode 实现:
- `plugin/core/channel-modes.ts` - 管理 channel 运行时状态
- `plugin/hooks/message-received.ts` - 检测 start/end marker 并切换模式
- `plugin/hooks/before-model-resolve.ts` - 在 multi-message mode 中强制 no-reply
- 配置项 `multiMessageStartMarker` (默认 `↗️`)、`multiMessageEndMarker` (默认 `↙️`)、`multiMessagePromptMarker` (默认 `⤵️`)
-`plugin/openclaw.plugin.json` 中添加了相应的配置 schema
- Shuffle Mode 实现:
- `plugin/core/channel-modes.ts` - 管理 shuffle 状态
- `plugin/turn-manager.ts` - 在每轮结束后根据 shuffle 设置决定是否重洗牌
- `/dirigent turn-shuffling` slash command 实现,支持 `on`/`off`/`status` 操作
- 确保上一轮最后发言者不会在下一轮中成为第一位
- 当前行为是运行时开关,默认关闭,不落盘
## 7. 验收清单
### Multi-Message Mode 验收
- [x] 人类发送 start marker (`↗️`) 后进入 multi-message 模式
- [x] multi-message 模式中 Agent 被 no-reply 覆盖
- [x] 每条人类追加消息都触发 prompt marker (`⤵️`)
- [x] 人类发送 end marker (`↙️`) 后退出 multi-message 模式
- [x] 退出后 moderator 正确唤醒下一位 Agent
- [x] moderator prompt marker 不会触发回环
- [x] 与 waiting-for-human 模式兼容
- [x] 与 mention override 模式兼容
### Shuffle Mode 验收
- [x] `/dirigent turn-shuffling on/off` 命令生效
- [x] shuffling 关闭时 turn order 保持不变
- [x] shuffling 开启时每轮结束后会重洗牌
- [x] 上一轮最后发言者不会在下一轮中成为第一位
- [x] 双 Agent 场景行为符合预期
- [x] 单 Agent 场景不会异常
- [x] 与 dormant 状态兼容
- [x] 与 mention override 兼容
### 配置项验收
- [x] `multiMessageStartMarker` 配置项生效
- [x] `multiMessageEndMarker` 配置项生效
- [x] `multiMessagePromptMarker` 配置项生效
- [x] 配置项在 `plugin/openclaw.plugin.json` 中正确声明
## 8. 结论
Multi-Message Mode 与 Shuffle Mode 已成功集成到 Dirigent 插件中,与现有的 turn-manager、moderator handoff、no-reply override 机制协同工作,为用户提供更灵活的多 Agent 协作控制能力。

588
plans/CSM.md Normal file
View File

@@ -0,0 +1,588 @@
# CSM 设计稿:基于 Discord 私密频道的 Agent 协作讨论与回调机制
## 1. 背景
在 Dirigent 的多 Agent 工作流中,某个正在执行任务的 Agent 可能在处理中途需要与其他 Agent 讨论某个问题,例如:
- 方案评审
- 权限模型确认
- 实现细节对齐
- 风险/边界条件讨论
当前缺少一种受控、可回收、可回灌原工作流的讨论机制。目标是让 Agent 能在需要时主动拉起一个临时私密讨论频道,与指定 Agent 交流,并在结束后把讨论成果注入回原始工作 Session 所在的 Discord channel。
本方案采用:
- 由发起讨论的 Agent 直接调用 `discord_channel_create`
- 为该工具新增两个可选参数,以标记“讨论模式”
- 由插件侧 moderator bot 用纯字符串拼接方式驱动讨论流程
- 由发起讨论的 Agent 在讨论结束时显式调用新增工具 `discuss-callback`
- 回调后关闭讨论 channel并唤醒原工作 channel 继续处理
---
## 2. 目标
实现一种可控的 Agent 间讨论机制,满足以下要求:
1. Agent 能在工作过程中主动创建一个私密讨论 channel
2. 讨论 channel 能通过固定规则唤醒参与 Agent 开始讨论
3. 讨论结束时,结果必须以文件形式落地
4. 讨论结果能回传到原工作 channel
5. 讨论结束后,讨论 channel 不再继续处理任何 Agent 发言
6. moderator bot 不使用任何模型,所有发言均为固定模板/字符串拼接
7. 整个机制尽量复用现有插件中的 turn-manager 轮转发言能力
---
## 3. 非目标
以下内容不在本次 MVP 范围内:
- 不做基于模型的 moderator/coordinator 智能决策
- 不做自动总结(总结必须由发起讨论的 Agent 负责)
- 不做复杂投票/仲裁机制
- 不做多级讨论嵌套
- 不做跨 workspace 的结果存储
- 不做开放式 transcript 自动压缩和长期知识抽取
---
## 4. 核心思路
将“讨论”视为一种特殊模式的私密频道:
- 由工作中的 Agent 主动创建
- 创建时提供原工作 channel idcallback channel id和讨论指引discuss guide
- 插件识别该频道为特殊讨论频道
- moderator bot 通过插件内部 `sendModeratorMessage(...)` 在频道中发布一条固定 kickoff message说明
- 讨论目的
- 应如何进行讨论
- 结束条件
- 发起讨论的 Agent 在结束时必须写总结文档并调用 `discuss-callback`
- 讨论期间复用现有 turn-manager 轮转发言机制驱动 Agent 发言
- 如果轮转一圈无人发言,则 moderator 提醒发起者执行 callback 收尾
- callback 成功后:
- 检查总结文档是否存在,且必须位于发起讨论 Agent 的 workspace 下
- 将讨论 channel 中对应 session 的 provider/model override 到 `noReplyProvider` / `noReplyModel`
- 讨论 channel 后续任何消息都不再参与讨论,仅由 moderator 回复“channel 已关闭,仅留档”
- moderator 在原工作 channel 发消息,给出讨论结果文档路径,唤醒原工作流
---
## 5. 工具设计
### 5.1 扩展 `discord_channel_create`
在现有 `discord_channel_create` 工具上新增两个可选参数:
- `callbackChannelId?: string`
- `discussGuide?: string`
#### 兼容性要求
如果未填写 `callbackChannelId`
- 工具行为与当前完全一致
如果填写了 `callbackChannelId`
- 则必须同时填写 `discussGuide`
- 插件将该 channel 标记为“讨论模式 channel”
#### 参数语义
##### `callbackChannelId`
表示原工作 Session 所在的 Discord channel id。
该参数用于在讨论结束时,将结果回传给原工作 channel。
##### `discussGuide`
字符串,描述:
- 需要讨论什么
- 讨论希望得到什么结论
- 讨论的边界与目标
- 可选的产出要求
例如:
```text
讨论私密协作 channel 的回调机制,需要明确:
1. `discord_channel_create` 扩展参数设计
2. discuss-callback 工具的行为
3. 讨论结束后的 session 收口方式
4. MVP 范围内的异常处理策略
```
---
### 5.2 新工具 `discuss-callback`
新增工具:`discuss-callback`
建议工具参数最小化为:
- `summaryPath: string`
#### 参数说明
##### `summaryPath`
讨论总结文档的路径。
要求:
- 文件必须存在
- 文件必须位于发起讨论 Agent 的 workspace 下
- 不允许引用 workspace 外路径
#### 不建议暴露的参数
以下参数不应由 Agent 自己传入:
- 原工作 channel id
- discussion channel id
- 发起者身份
这些信息应由插件根据当前 channel 上下文和已记录 metadata 自动推导,避免伪造和串错。
---
## 6. 讨论模式 Channel 的元数据
`discord_channel_create` 以讨论模式创建 channel 时,插件需要记录对应 metadata。
建议结构如下:
```json
{
"mode": "discussion",
"discussionChannelId": "C_DISCUSS_001",
"originChannelId": "C_WORK_001",
"initiatorAgentId": "agent_planner",
"initiatorSessionId": "sess_123",
"discussGuide": "讨论如何设计 callback 机制并确定 MVP 行为",
"status": "active",
"createdAt": "2026-03-30T18:00:00Z",
"summaryPath": null
}
```
### 必需字段说明
- `mode`: 固定为 `discussion`
- `discussionChannelId`: 当前讨论 channel id
- `originChannelId`: 原工作 channel id
- `initiatorAgentId`: 发起讨论的 Agent 标识
- `initiatorSessionId`: 发起讨论对应的 session 标识
- `discussGuide`: 讨论说明
- `status`: 生命周期状态
- `createdAt`: 创建时间
- `summaryPath`: 结束后回写的总结文档路径
### 生命周期状态建议
- `active`:讨论进行中
- `completed`:已成功 callback已完成
- `closed`:已关闭,仅作留档
对于 MVP也可以将 `completed` 直接视为 `closed` 前的瞬时态,只要实现上清晰即可。
---
## 7. moderator bot 行为设计
moderator bot 的工作流完全不使用模型,所有输出均由模板字符串拼接生成。
### 7.1 讨论启动时发言
当检测到某 channel 为讨论模式 channel 时moderator bot 自动发 kickoff message。
建议内容结构如下(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`
```text
[Discussion Started]
This channel was created for a temporary agent discussion.
Goal:
{discussGuide}
Instructions:
1. Discuss only the topic above.
2. Work toward a concrete conclusion.
3. When the initiator decides the goal has been achieved, the initiator must:
- write a summary document to a file
- call the tool: discuss-callback(summaryPath)
- provide the summary document path
Completion rule:
Only the discussion initiator may finish this discussion.
After callback:
- this channel will be closed
- further discussion messages will be ignored
- this channel will remain only for archive/reference
- the original work channel will be notified with the summary file path
```
结尾必须明确表达以下语义:
- 讨论结束由发起讨论的 Agent 判定
- 发起者结束时必须写入总结文档
- 发起者必须调用 `discuss-callback`
- callback 成功后,原工作 channel 会被通知结果文档路径
---
### 7.2 空转提醒机制
插件当前已有基于 turn-manager 的轮转发言机制。
现有逻辑:
- 发言列表轮转一圈,如果没有人发言且所有人均回复 `NO_REPLY`,则认为 channel 休眠
本方案对“特殊讨论 channel”修改为
#### 新逻辑
在讨论模式 channel 中:
- 若发言列表轮转一圈,无人发言
- 则 moderator 不直接判定流程结束
- 改为提醒发起讨论的 Agent
- 若讨论目标已经达成,请写总结文档
- 然后调用 `discuss-callback` 结束讨论
建议提醒消息模板:
```text
[Discussion Idle]
No agent responded in the latest discussion round.
If the discussion goal has already been achieved, the initiator should now:
1. write the discussion summary to a file in the workspace
2. call discuss-callback with the summary file path
This reminder does not mean the discussion was automatically summarized or closed.
If more discussion is still needed, continue the discussion in this channel.
```
这样可以避免讨论悄无声息悬空,同时不引入自动总结逻辑。
---
### 7.3 讨论结束后的行为
`discuss-callback` 成功后:
1. 讨论 channel 标记为关闭/完成
2. 讨论 channel 中所有相关 session 的 provider/model override 到 `noReplyProvider` / `noReplyModel`
3. 后续任何在该 channel 中的发言均不再触发实际讨论流程
4. 如果讨论结束后仍有人发言,由 moderator 统一回复:
- `channel 已关闭channel 仅做留档使用`
建议模板:
```text
[Channel Closed]
This discussion channel has been closed.
It is now kept for archive/reference only.
Further discussion in this channel is ignored.
If follow-up work is needed, continue it from the origin work channel instead.
```
这部分实现明确采用已有“在指定 session 上临时覆盖为 no-reply 模型”的方式,而不是修改 Agent 的全局默认模型。
---
## 8. `discuss-callback` 工具行为细节
### 8.1 调用前提
仅允许在讨论模式 channel 中调用。
### 8.2 调用权限
只有发起讨论的 Agent 对应 session 可以成功调用。
其他参与 Agent 调用时应被拒绝。
### 8.3 调用流程
`discuss-callback(summaryPath)` 执行时,插件执行如下步骤:
1. 从当前调用上下文获取 discussion channel id
2. 查询该 channel 的 discussion metadata
3. 校验该 channel 是否为讨论模式 channel
4. 校验调用者是否为 initiator session / initiator agent
5. 校验 `summaryPath` 对应文件是否存在
6. 校验文件路径是否位于发起讨论 Agent 的 workspace 下
7. 更新 metadata
- `summaryPath = ...`
- `status = completed``closed`
- `completedAt = now`
8. 将该 discussion channel 中相关 session 的模型覆盖为 `NO_REPLY`
9. 在原工作 channel 中由 moderator bot 发消息,给出结果文档路径
10. 讨论 channel 后续进入只留档、不再参与讨论的状态
---
## 9. 原工作 Channel 的唤醒机制
`discuss-callback` 成功后moderator bot 需要在原工作 channel 发一条固定格式消息。
目的:
- 向原工作 Session 明确传达“讨论结束”
- 提供结果文档路径
- 用新消息唤醒原工作 channel 上的 Agent 继续执行
建议模板(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`
```text
[Discussion Result Ready]
A temporary discussion has completed.
Summary file:
{summaryPath}
Source discussion channel:
<#discussionChannelId>
Status:
completed
Continue the original task using the summary file above.
```
### 注意事项
这里的“唤醒”不依赖额外模型调度逻辑,而是依赖:
- moderator bot 在原 channel 发布一条新消息
- 原工作 channel 正常接收到新消息事件
- 原工作 Session 被自然触发继续处理
---
## 10. 安全与边界条件
### 10.1 `summaryPath` 伪造
必须防止 Agent 传入伪造路径。
#### 强制校验
插件必须检查:
1. 文件是否存在
2. 文件路径是否位于发起讨论 Agent 的 workspace 下
3. 不允许使用 workspace 之外的路径
这是本方案的硬约束。
---
### 10.2 discussion channel 未正常结束
由于讨论结束依赖发起者显式 callback可能出现讨论完成但发起者未执行 callback 的情况。
#### MVP 策略
- 不做自动总结
- 复用顺序讨论机制检测“轮转一圈无人发言”
- 由 moderator bot 提醒发起者收尾
这已经足够形成最低可用闭环。
后续增强可再考虑:
- 超时提醒
- stale discussion 清理
- 管理员强制关闭
---
### 10.3 结束后继续发言
这是明确禁止的。
在讨论完成后:
- 任何会话都不应继续在该 channel 中讨论
- 相关 session 继续使用 `NO_REPLY` 模型覆盖
- channel 仅保留为留档用途
- 后续发言统一由 moderator bot 回复 channel 已关闭
这条约束需要严格执行,避免旧讨论“诈尸”。
---
### 10.4 状态重复提交
`discuss-callback` 被重复调用,应拒绝处理。
建议规则:
- 仅允许 `active -> completed/closed` 一次转换
- 若 channel 已完成或已关闭,则后续 callback 一律失败
---
## 11. 推荐状态机
```text
普通 channel
└── 不受本方案影响
讨论 channel
created
-> active
-> idle-reminded (可选显示态)
-> completed
-> closed
```
### 状态说明
- `created`channel 已创建但 kickoff 尚未发完
- `active`:讨论进行中
- `idle-reminded`轮转一圈无人发言moderator 已提醒收尾(实现上可不单独持久化)
- `completed`callback 已成功
- `closed`channel 已关闭,仅保留留档
MVP 中也可简化为:
- `active`
- `closed`
只要逻辑上能区分 callback 前后即可。
---
## 12. 典型流程示例
### 12.1 发起讨论
原工作 channel 中,某 Agent 发现需要和其他 Agent 对齐方案。
Agent 调用:
```json
{
"callbackChannelId": "origin-channel-123",
"discussGuide": "讨论私密协作 channel 的结束与回调机制,需要产出一份总结说明工具参数、结束条件和异常处理策略。"
}
```
插件创建私密讨论 channel并记录 metadata。
moderator bot 在新 channel 中发布 kickoff message。
---
### 12.2 展开讨论
参与 Agent 在该 channel 中按现有顺序讨论机制发言。
讨论 channel 的 participant 集合继续复用现有 channel member bootstrap 逻辑:由 `plugin/core/turn-bootstrap.ts` 调用 `fetchVisibleChannelBotAccountIds(...)` 基于 Discord 可见成员与已有 account 映射发现可参与的 bot account再交给 `initTurnOrder(...)` 建立轮转状态,而不是为 discussion 模式额外维护一套成员发现流程。
如果轮转一圈无人发言,则 moderator 提醒发起者:
- 若已达成目标,请写总结文档并 callback
---
### 12.3 结束讨论
发起讨论 Agent 在自己的 workspace 下写出总结文档,例如:
```text
plans/discussions/csm-discussion-summary.md
```
随后调用:
```json
{
"summaryPath": "plans/discussions/csm-discussion-summary.md"
}
```
插件校验通过后:
- 标记讨论完成
- 覆盖讨论 channel 中相关 session 的模型为 `NO_REPLY`
- 在原工作 channel 发 moderator message
---
### 12.4 原工作流继续
原工作 channel 收到 moderator 的结果通知:
- 结果文档路径
- 来源 discussion channel
- 状态 completed
原工作 Session 读取该总结文档,并继续原任务。
---
## 13. MVP 实施建议
建议按以下顺序实现:
### 第一步:扩展建频道工具
-`create private channel` 增加 `callbackChannelId``discussGuide`
- 实现参数联动校验
- 讨论模式下写入 metadata
### 第二步moderator kickoff
- 讨论模式 channel 创建后自动发固定模板消息
- 内容包含 callback 操作说明
### 第三步:接入空转提醒
- 复用现有 turn-manager 轮转逻辑
- 在讨论模式 channel 中改为空转提醒发起者 callback
### 第四步:实现 `discuss-callback`
- 当前 channel 识别
- initiator 身份校验
- `summaryPath` 文件存在校验
- workspace 范围校验
- metadata 更新
### 第五步:完成关闭逻辑
- 指定 session 的 provider/model override 到 `noReplyProvider` / `noReplyModel`
- 结束后任何发言统一由 moderator 回复 channel 已关闭
### 第六步:原 channel 唤醒
- moderator 在 origin channel 发布结果消息
- 确保原工作 Session 被自然唤醒
---
## 14. 后续可扩展方向
在 MVP 跑通后,可考虑增加以下能力:
- 讨论超时自动提醒
- 管理员强制 callback / 强制关闭
- 讨论文档路径规范化
- 对总结文档结构给出模板约束
- discussion 状态查询工具
- 讨论留档索引
- callback 成功后自动附带摘要预览
但这些都应放在 MVP 验证后再做。
---
## 15. 结论
本方案将 Agent 间讨论能力实现为一种“特殊模式的私密 Discord channel”通过
- 扩展 `discord_channel_create`
- 新增 `discuss-callback`
- 复用现有 turn-manager 轮转发言机制
- 使用纯规则驱动的 moderator bot
- 在指定 session 上临时应用 `noReplyProvider` / `noReplyModel` 覆盖
形成一个完整闭环:
1. 原工作 Agent 发起讨论
2. 私密讨论 channel 被创建并自动启动讨论
3. 讨论结果写入发起者 workspace 中的文件
4. 发起者显式 callback 结束讨论
5. 插件关闭讨论 channel 的交互能力
6. moderator 将结果路径回传到原工作 channel
7. 原工作 Session 被唤醒并继续执行
这是一个实现成本可控、行为稳定、边界清晰、适合 MVP 落地的方案。

382
plans/TASKLIST.md Normal file
View File

@@ -0,0 +1,382 @@
# TASKLIST - Dirigent 开发任务拆分
## A. CSM / Discussion Callback
### A1. 需求与方案冻结
- [x] 通读并确认 `plans/CSM.md` 中的 MVP 范围、非目标和边界条件
- [x] 确认 CSM 第一版只新增一条对外工具:`discuss-callback`
- [x] 确认 `discord_channel_create` 仅做参数扩展,不改变普通建频道行为
- [x] 确认讨论结束后的 session 级 no-reply 覆盖继续沿用现有插件机制
- [x] 确认 `summaryPath` 的合法范围仅限发起讨论 Agent 的 workspace
- [x] 确认 discussion metadata 是否仅做内存态,还是需要落盘恢复
### A2. 模块拆分与落点确认
- [. ] 确认 `plugin/tools/register-tools.ts` 负责:
- [. ] 扩展 `discord_channel_create`
- [. ] 注册 `discuss-callback`
- [. ] 确认 `plugin/core/` 下新增 discussion metadata 管理模块
- [x] 确认 `plugin/core/moderator-discord.ts` 继续负责 moderator 发消息能力
- [x] 确认 `plugin/turn-manager.ts` 仅负责 turn 状态与轮转,不直接承担业务文案拼接
- [x] 确认 discussion 业务编排逻辑应放在新模块,而不是散落到多个 hook 中
- [x] 确认 origin callback 与 discussion close 逻辑的调用路径
### A3. `plugin/tools/register-tools.ts`
#### A3.1 扩展 `discord_channel_create`
- [x] 阅读当前 `discord_channel_create` 的参数定义与执行逻辑
- [x]`discord_channel_create` 增加可选参数 `callbackChannelId`
- [x]`discord_channel_create` 增加可选参数 `discussGuide`
- [x] 添加联动校验:若传 `callbackChannelId`,则必须传 `discussGuide`
- [x] 保证不传 `callbackChannelId` 时,行为与当前版本完全一致
- [x] 创建成功后识别是否为 discussion 模式 channel
- [x] 在 discussion 模式下调用 metadata 初始化逻辑
- [x] 在 discussion 模式下调用 moderator kickoff 发送逻辑
#### A3.2 注册 `discuss-callback`
- [x] 定义 `discuss-callback` 的 parameters schema
- [x] 注册新工具 `discuss-callback`
- [x]`discuss-callback` 执行逻辑接到 discussion service / manager
- [x] 为工具失败场景返回可读错误信息
### A4. `plugin/core/` 新增 discussion metadata/service 模块
#### A4.1 新建 metadata/state 模块
- [x] 新建 discussion state 类型定义文件(如 `plugin/core/discussion-state.ts`
- [x] 定义 discussion metadata 类型:
- [x] `mode`
- [x] `discussionChannelId`
- [x] `originChannelId`
- [x] `initiatorAgentId`
- [x] `initiatorSessionId`
- [x] `discussGuide`
- [x] `status`
- [x] `createdAt`
- [x] `completedAt`
- [x] `summaryPath`
- [x] 提供按 `discussionChannelId` 查询 metadata 的方法
- [x] 提供创建 metadata 的方法
- [x] 提供更新状态的方法
- [x] 提供关闭 discussion channel 的状态写入方法
#### A4.2 新建 discussion service 模块
- [x] 新建 discussion service`plugin/core/discussion-service.ts`
- [x] 封装 discussion channel 创建后的初始化逻辑
- [x] 封装 callback 校验逻辑
- [x] 封装 callback 成功后的收尾逻辑
- [x] 封装 origin channel moderator 通知逻辑
- [x] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑
#### A4.3 workspace 路径校验
- [x] 新增 path 校验辅助函数
- [x] 校验 `summaryPath` 文件存在
- [x] 校验 `summaryPath` 位于 initiator workspace 下
- [x] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题
### A5. `plugin/core/moderator-discord.ts`
- [x] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程
- [x] 如有必要,补充统一错误日志和返回值处理
- [x] 确认可被 discussion service 复用发送:
- [x] kickoff message
- [x] idle reminder
- [x] callback 完成通知
- [x] channel closed 固定回复
### A6. `plugin/turn-manager.ts`
#### A6.1 理解现有轮转机制
- [x] 梳理 `initTurnOrder` / `checkTurn` / `onNewMessage` / `onSpeakerDone` / `advanceTurn`
- [x] 确认“轮转一圈无人发言”在现有实现中的判定条件
- [x] 确认 discussion 模式需要在哪个信号点插入“idle reminder”
#### A6.2 discussion 模式的空转处理
- [x] 设计 discussion 模式下的 idle reminder 触发方式
- [x] 确定是直接改 `turn-manager.ts`,还是由上层在 `nextSpeaker === null` 时识别 discussion channel
- [x] 确保 discussion channel 空转时 moderator 会提醒 initiator 收尾
- [x] 确保普通 channel 仍保持原有 dormant 行为
#### A6.3 关闭后禁言
- [x] 明确 discussion channel `closed` 后 turn-manager 是否还需要保留状态
- [x] 如需要,增加对 closed discussion channel 的快速短路判断
- [x] 避免 closed channel 再次进入正常轮转
### A7. Hooks 与 session 状态
#### A7.1 `plugin/hooks/before-model-resolve.ts`
- [x] 梳理当前 session 级 no-reply 覆盖的触发路径
- [x] 确认如何将 closed discussion channel 相关 session 强制落到 `noReplyProvider` / `noReplyModel`
- [x] 确认该逻辑是通过 metadata 状态判断,还是通过额外 session 标记判断
- [x] 确保该覆盖只作用于指定 discussion session不影响其他 channel/session
- [x] 为 closed discussion channel 的覆盖路径补充调试日志
#### A7.2 `plugin/hooks/before-message-write.ts`
- [x] 梳理当前 NO_REPLY / end-symbol / waitIdentifier 的处理逻辑
- [x] 找到 discussion channel 中“轮转一圈无人发言”后最适合触发 idle reminder 的位置
- [x] 如果 `nextSpeaker === null` 且当前 channel 是 active discussion channel
- [x] 调用 moderator idle reminder
- [x] 不直接让流程无提示沉默结束
- [x] 避免重复发送 idle reminder
- [x] closed discussion channel 下,阻止继续进入正常 handoff 流程
#### A7.3 `plugin/hooks/message-sent.ts`
- [x] 确认该 hook 是否也会参与 turn 收尾,避免与 `before-message-write.ts` 重复处理
- [x] 检查 discussion channel 场景下是否需要同步补充 closed/idle 分支保护
- [x] 确保 callback 完成后的 closed channel 不会继续触发 handoff
#### A7.4 `plugin/hooks/message-received.ts`
- [x] 梳理 moderator bot 消息当前是否已被过滤,避免 moderator 自己再次触发讨论链路
- [x] 对 closed discussion channel 的新消息增加统一处理入口
- [x] 若 closed discussion channel 收到新消息:
- [x] 不再唤醒任何 Agent 正常讨论
- [x] 由 moderator 回复“channel 已关闭,仅做留档使用”
- [x] 避免 moderator 的 closed 提示消息反复触发自身处理
#### A7.5 `plugin/core/session-state.ts`(如需)
- [x] 检查现有 session 相关缓存是否适合扩展 discussion 状态
- [x] 若需要,为 discussion session 增加专用标记缓存
- [x] 区分:普通 no-reply 决策 vs discussion close 强制 no-reply
- [x] 确保 session 生命周期结束后相关缓存可清理
### A8. `plugin/core/identity.ts` / `plugin/core/channel-members.ts` / `plugin/core/turn-bootstrap.ts`
- [x] 梳理 initiator identity 的可获取路径
- [x] 确认 callback 时如何稳定识别 initiator account/session
- [x] 确认 discussion channel 创建后 turn order 是否需立即 bootstrap
- [x] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑
### A9. `plugin/index.ts`
- [x] 注入新增 discussion metadata/service 模块依赖
- [x] 将 discussion service 传入工具注册逻辑
- [x] 将 discussion 相关辅助能力传入需要的 hooks
- [x] 保持插件初始化结构清晰,避免在 `index.ts` 中堆业务细节
### A13.2 metadata / service 测试
- [x] 测试 discussion metadata 创建成功
- [x] 测试按 channelId 查询 metadata 成功
- [x] 测试状态流转 `active -> completed/closed` 成功
- [x] 测试重复 callback 被拒绝
### A13.4 路径校验测试
- [x] 测试合法 `summaryPath` 通过
- [x] 测试不存在文件失败
- [x] 测试 workspace 外路径失败
- [x] 测试 `..` 路径逃逸失败
- [x] 测试绝对路径越界失败
### A13.5 回调链路测试
- [x] 测试 callback 成功后 moderator 在 origin channel 发出通知
- [x] 测试 origin channel 收到路径后能继续原工作流
- [x] 测试 discussion channel 后续只保留留档行为
### B10.2 Shuffle Mode
- [x] 测试 `/turn-shuffling on/off` 生效
- [x] 测试 shuffling 关闭时 turn order 不变
- [x] 测试 shuffling 开启时每轮结束后会 reshuffle
- [x] 测试上一轮最后 speaker 不会成为下一轮第一位
- [x] 测试双 Agent 场景行为符合预期
- [x] 测试单 Agent 场景不会异常
### B10.3 兼容性测试
- [x] 测试 multi-message mode 与 waiting-for-human 的边界
- [x] 测试 multi-message mode 与 mention override 的边界
- [x] 测试 shuffle mode 与 dormant 状态的边界
- [x] 测试 shuffle mode 与 mention override 的边界
### B11. 文档收尾
- [x] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [x] 为新增配置项补文档
- [x]`/turn-shuffling` 补使用说明
- [x] 输出 Multi-Message Mode / Shuffle Mode 的验收清单
### A10. moderator 消息模板整理
#### A10.1 kickoff message
- [x] 定稿 discussion started 模板
- [x] 模板中包含 `discussGuide`
- [x] 模板中明确 initiator 结束责任
- [x] 模板中明确 `discuss-callback(summaryPath)` 调用要求
#### A10.2 idle reminder
- [x] 定稿 discussion idle 模板
- [x] 模板中提醒 initiator写总结文件并 callback
- [x] 避免提醒文案歧义或像自动总结器
#### A10.3 origin callback message
- [x] 定稿发回原工作 channel 的结果通知模板
- [x] 模板中包含 `summaryPath`
- [x] 模板中包含来源 discussion channel
- [x] 模板中明确“继续基于该总结文件推进原任务”
#### A10.4 closed reply
- [x] 定稿 closed channel 固定回复模板
- [x] 明确 channel 已关闭,仅做留档使用
### A11. `discuss-callback` 详细校验任务
- [x] 校验当前 channel 必须是 discussion channel
- [x] 校验当前 discussion 状态必须是 `active`
- [x] 校验调用者必须是 initiator
- [x] 校验 `summaryPath` 非空
- [x] 校验 `summaryPath` 文件存在
- [x] 校验 `summaryPath` 路径在 initiator workspace 内
- [x] 校验 callback 未重复执行
- [x] callback 成功后写入 `completedAt`
- [x] callback 成功后记录 `summaryPath`
- [x] callback 成功后切换 discussion 状态为 `completed` / `closed`
### A12. 关闭后的行为封口
- [x] closed discussion channel 中所有旧 session 继续使用 no-reply 覆盖
- [x] closed discussion channel 中任何新消息都不再进入真实讨论
- [x] closed discussion channel 的任何新消息统一走 moderator 固定回复
- [x] 防止 closed channel 中 moderator 自己的回复再次触发回环
- [x] 明确 archived-only 的最终行为与边界
### A13. 测试与文档收尾
#### A13.1 工具层测试
- [x] 测试普通 `discord_channel_create` 不带新参数时行为不变
- [x] 测试 `discord_channel_create``callbackChannelId` 但缺 `discussGuide` 时失败
- [x] 测试 discussion 模式 channel 创建成功
- [x] 测试 `discuss-callback` 注册成功并可调用
#### A13.2 metadata / service 测试
- [x] 测试 discussion metadata 创建成功
- [x] 测试按 channelId 查询 metadata 成功
- [x] 测试状态流转 `active -> completed/closed` 成功
- [x] 测试重复 callback 被拒绝
#### A13.3 turn / hook 测试
- [x] 测试 discussion channel 空转后发送 idle reminder
- [x] 测试普通 channel 空转逻辑不受影响
- [x] 测试 callback 成功后 discussion channel 不再 handoff
- [x] 测试 closed discussion channel 新消息不会继续唤醒 Agent
#### A13.4 路径校验测试
- [x] 测试合法 `summaryPath` 通过
- [x] 测试不存在文件失败
- [x] 测试 workspace 外路径失败
- [x] 测试 `..` 路径逃逸失败
- [x] 测试绝对路径越界失败
#### A13.5 回调链路测试
- [x] 测试 callback 成功后 moderator 在 origin channel 发出通知
- [x] 测试 origin channel 收到路径后能继续原工作流
- [x] 测试 discussion channel 后续只保留留档行为
#### A13.6 文档交付
- [x] 根据最终代码实现更新 `plans/CSM.md`
- [x]`discord_channel_create` 新增参数补文档
- [x]`discuss-callback` 补工具说明文档
- [x] 补 discussion metadata 与状态机说明
- [x] 补开发/调试说明
- [x] 输出 MVP 验收清单
---
## B. Multi-Message Mode / Shuffle Mode
### B1. 方案整理
- [x] 通读并确认 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [x] 确认 Multi-Message Mode 与 Shuffle Mode 的 MVP 范围
- [x] 确认两项能力是否都只做 channel 级 runtime state不立即落盘
- [x] 明确它们与 discussion channel / waiting-for-human / dormant 的优先级关系
### B2. 配置与 schema
#### B2.1 `plugin/openclaw.plugin.json`
- [x] 增加 `multiMessageStartMarker`
- [x] 增加 `multiMessageEndMarker`
- [x] 增加 `multiMessagePromptMarker`
- [x] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️`
- [x] 评估是否需要增加 shuffle 默认配置项
#### B2.2 `plugin/rules.ts` / config 类型
- [x] 为 multi-message mode 相关配置补类型定义
- [x] 为 shuffle mode 相关 channel state / config 补类型定义
- [x] 确保运行时读取配置逻辑可访问新增字段
### B3. `plugin/core/` 新增 channel mode / shuffle state 模块
- [x] 新增 channel mode state 模块(如 `plugin/core/channel-modes.ts`
- [x] 定义 channel mode`normal` / `multi-message`
- [x] 提供 `enterMultiMessageMode(channelId)`
- [x] 提供 `exitMultiMessageMode(channelId)`
- [x] 提供 `isMultiMessageMode(channelId)`
- [x] 提供 shuffle 开关状态存取方法
- [x] 评估 shuffle state 是否应并入 turn-manager 内部状态
### B4. `plugin/hooks/message-received.ts`
#### B4.1 Multi-Message Mode 入口/出口
- [x] 检测 human 消息中的 multi-message start marker
- [x] start marker 命中时,将 channel 切换到 multi-message mode
- [x] 检测 human 消息中的 multi-message end marker
- [x] end marker 命中时,将 channel 退出 multi-message mode
- [x] 避免 moderator 自己的 prompt marker 消息触发 mode 切换
#### B4.2 Multi-Message Mode 中的 moderator 提示
- [x] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker
- [x] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker`
- [x] 避免重复触发或回环
#### B4.3 与现有 mention override 的兼容
- [x] 明确 multi-message mode 下 human @mention 是否忽略
- [x] 避免 multi-message mode 与 mention override 冲突
### B5. `plugin/hooks/before-model-resolve.ts`
- [x] 当 channel 处于 multi-message mode 时,强制相关 session 走 `noReplyProvider` / `noReplyModel`
- [x] 确保 multi-message mode 的 no-reply 覆盖优先于普通 turn 决策
- [x] 确保退出 multi-message mode 后恢复正常 turn 逻辑
- [x] 补充必要调试日志
### B6. `plugin/turn-manager.ts`
#### B6.1 Multi-Message Mode 与 turn pause/resume
- [x] 设计 multi-message mode 下 turn manager 的暂停语义
- [x] 明确 pause 是通过外层 gating还是 turn-manager 内显式状态
- [x] 退出 multi-message mode 后恢复 turn manager
- [x] 退出时确定下一位 speaker 的选择逻辑
#### B6.2 Shuffle Mode
- [x] 为每个 channel 增加 `shuffling` 开关状态
- [x] 识别“一轮最后位 speaker 发言完成”的边界点
- [x] 在进入下一轮前执行 reshuffle
- [x] 保证上一轮最后 speaker 不会成为新一轮第一位
- [x] 处理单 Agent 场景
- [x] 处理双 Agent 场景
- [x] 处理 mention override / waiting-for-human / dormant 状态下的 reshape 边界
### B7. `plugin/commands/dirigent-command.ts`
- [x] 新增 `/turn-shuffling` 子命令
- [x] 支持:
- [x] `/turn-shuffling`
- [x] `/turn-shuffling on`
- [x] `/turn-shuffling off`
- [x] 命令返回当前 channel 的 shuffling 状态
- [x] 命令帮助文本补充说明
### B8. `plugin/index.ts`
- [x] 注入 channel mode / shuffle state 模块依赖
- [x] 将新状态能力传给相关 hooks / turn-manager
- [x] 保持初始化关系清晰,避免 mode 逻辑散落
### B9. moderator 消息模板
- [x] 定义 multi-message mode 下的 prompt marker 发送规则
- [x] 明确是否需要 start / end 的 moderator 确认消息
- [x] 定义退出 multi-message mode 后的 scheduling handoff 触发格式
### B10. 测试
#### B10.1 Multi-Message Mode
- [x] 测试 human 发送 start marker 后进入 multi-message mode
- [x] 测试 multi-message mode 中 Agent 被 no-reply 覆盖
- [x] 测试每条 human 追加消息都触发 prompt marker
- [x] 测试 human 发送 end marker 后退出 multi-message mode
- [x] 测试退出后 moderator 正确 handoff 给下一位 Agent
- [x] 测试 moderator prompt marker 不会触发回环
#### B10.2 Shuffle Mode
- [x] 测试 `/turn-shuffling on/off` 生效
- [x] 测试 shuffling 关闭时 turn order 不变
- [x] 测试 shuffling 开启时每轮结束后会 reshuffle
- [x] 测试上一轮最后 speaker 不会成为下一轮第一位
- [x] 测试双 Agent 场景行为符合预期
- [x] 测试单 Agent 场景不会异常
#### B10.3 兼容性测试
- [x] 测试 multi-message mode 与 waiting-for-human 的边界
- [x] 测试 multi-message mode 与 mention override 的边界
- [x] 测试 shuffle mode 与 dormant 状态的边界
- [x] 测试 shuffle mode 与 mention override 的边界
### B11. 文档收尾
- [x] 根据最终实现更新 `plans/CHANNEL_MODES_AND_SHUFFLE.md`
- [x] 为新增配置项补文档
- [x]`/turn-shuffling` 补使用说明
- [x] 输出 Multi-Message Mode / Shuffle Mode 的验收清单

View File

@@ -1,12 +1,10 @@
# WhisperGate Plugin
# Dirigent Plugin
## Hook strategy
- `message:received` caches a per-session decision from deterministic rules.
- `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply.
- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is:
- `bypass_sender`
- `end_symbol:*`
- `before_prompt_build` prepends end-marker instruction + scheduling identifier instruction when decision allows speaking.
## Rules (in order)
@@ -30,16 +28,21 @@ Optional:
- `humanList` (default [])
- `agentList` (default [])
- `channelPoliciesFile` (per-channel overrides in a standalone JSON file)
- `enableWhispergatePolicyTool` (default true)
- `schedulingIdentifier` (default `➡️`) — moderator handoff identifier
- `enableDirigentPolicyTool` (default true)
- `multiMessageStartMarker` (default `↗️`)
- `multiMessageEndMarker` (default `↙️`)
- `multiMessagePromptMarker` (default `⤵️`)
Unified optional tool:
- `whispergateway_tools`
- `dirigent_tools`
- Discord actions: `channel-private-create`, `channel-private-update`, `member-list`
- Policy actions: `policy-get`, `policy-set-channel`, `policy-delete-channel`
- Turn actions: `turn-status`, `turn-advance`, `turn-reset`
- `bypassUserIds` (deprecated alias of `humanList`)
- `endSymbols` (default ["🔚"])
- `enableDiscordControlTool` (default true)
- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`)
- Discord control actions are executed in-plugin via Discord REST API (no `discordControlApiBaseUrl` needed)
- `discordControlApiToken`
- `discordControlCallerId`
- `enableDebugLogs` (default false)
@@ -51,20 +54,35 @@ Policy file behavior:
- loaded once on startup into memory
- runtime decisions read memory state only
- direct file edits do NOT affect memory state
- `whispergateway_tools` policy actions update memory first, then persist to file (atomic write)
- `dirigent_tools` policy actions update memory first, then persist to file (atomic write)
## Optional tool: `whispergateway_tools`
## Moderator handoff format
This plugin registers one unified optional tool: `whispergateway_tools`.
To use it, add tool allowlist entry for either:
- tool name: `whispergateway_tools`
- plugin id: `whispergate`
When the current speaker NO_REPLYs, the moderator bot sends: `<@NEXT_USER_ID>➡️`
Supported actions:
- Discord: `channel-private-create`, `channel-private-update`, `member-list`
- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel`
This is a non-semantic scheduling message. The scheduling identifier (`➡️` by default) carries no meaning — it simply signals the next agent to check chat history and decide whether to speak.
## Multi-message mode / shuffle mode
- Human sends the configured start marker (default `↗️`) → channel enters multi-message mode.
- While active, agents are forced to no-reply and the moderator sends only the configured prompt marker (default `⤵️`) after each additional human message.
- Human sends the configured end marker (default `↙️`) → channel exits multi-message mode and normal scheduling resumes.
- No separate moderator "entered/exited mode" confirmation message is sent; the markers themselves are the protocol.
- The first moderator message after exit uses the normal scheduling handoff format: `<@NEXT_USER_ID>➡️`.
- `/dirigent turn-shuffling`, `/dirigent turn-shuffling on`, and `/dirigent turn-shuffling off` control per-channel reshuffling between completed rounds.
## Slash command (Discord)
```
/dirigent status
/dirigent turn-status
/dirigent turn-advance
/dirigent turn-reset
/dirigent turn-shuffling
/dirigent turn-shuffling on
/dirigent turn-shuffling off
```
Debug logging:
- set `enableDebugLogs: true` to emit detailed hook diagnostics
- optionally set `debugLogChannelIds` to only log selected channel IDs
- logs include key ctx fields + decision status at `message_received`, `before_model_resolve`, `before_prompt_build`

View File

@@ -0,0 +1,136 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { execFileSync } from "node:child_process";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
function getSkillBaseDir(api: OpenClawPluginApi): string {
return (api.config as Record<string, unknown>)?.["dirigentStateDir"] as string || path.join(os.homedir(), ".openclaw");
}
function parseGuildTable(skillMdContent: string): Array<{ guildId: string; description: string }> {
const lines = skillMdContent.split("\n");
const rows: Array<{ guildId: string; description: string }> = [];
let inTable = false;
for (const line of lines) {
// Detect table header
if (line.includes("guild-id") && line.includes("description")) {
inTable = true;
continue;
}
// Skip separator line
if (inTable && /^\|[-\s|]+\|$/.test(line)) {
continue;
}
// Parse data rows
if (inTable) {
const match = line.match(/^\| \s*(\d+) \s*\| \s*(.+?) \s*\|$/);
if (match) {
rows.push({ guildId: match[1].trim(), description: match[2].trim() });
}
}
}
return rows;
}
export function registerAddGuildCommand(api: OpenClawPluginApi): void {
// Register add-guild command
api.registerCommand({
name: "add-guild",
description: "Add a Discord guild to the discord-guilds skill",
acceptsArgs: true,
handler: async (cmdCtx) => {
const args = (cmdCtx.args || "").trim();
if (!args) {
return {
text: "Usage: /add-guild <guild-id> <description>\nExample: /add-guild 123456789012345678 \"Production server\"",
isError: true,
};
}
const parts = args.split(/\s+/);
if (parts.length < 2) {
return {
text: "Error: Both guild-id and description are required.\nUsage: /add-guild <guild-id> <description>",
isError: true,
};
}
const guildId = parts[0];
const description = parts.slice(1).join(" ");
// Validate guild ID
if (!/^\d+$/.test(guildId)) {
return {
text: "Error: guild-id must be a numeric Discord snowflake (e.g., 123456789012345678)",
isError: true,
};
}
// Resolve the skill script path
const openClawDir = getSkillBaseDir(api);
const scriptPath = path.join(openClawDir, "skills", "discord-guilds", "scripts", "add-guild");
try {
const result = execFileSync(process.execPath, [scriptPath, guildId, description], {
encoding: "utf8",
timeout: 5000,
});
return { text: result.trim() };
} catch (e: any) {
const stderr = e?.stderr?.toString?.() || "";
const stdout = e?.stdout?.toString?.() || "";
return {
text: `Failed to add guild: ${stderr || stdout || String(e)}`,
isError: true,
};
}
},
});
// Register list-guilds command
api.registerCommand({
name: "list-guilds",
description: "List all Discord guilds in the discord-guilds skill",
acceptsArgs: false,
handler: async () => {
const openClawDir = getSkillBaseDir(api);
const skillMdPath = path.join(openClawDir, "skills", "discord-guilds", "SKILL.md");
if (!fs.existsSync(skillMdPath)) {
return {
text: "Error: discord-guilds skill not found. Run Dirigent install first.",
isError: true,
};
}
try {
const content = fs.readFileSync(skillMdPath, "utf8");
const guilds = parseGuildTable(content);
if (guilds.length === 0) {
return { text: "No guilds configured yet.\n\nUse /add-guild <guild-id> <description> to add one." };
}
const lines = [
`**Available Guilds (${guilds.length}):**`,
"",
"| guild-id | description |",
"|----------|-------------|",
...guilds.map(g => `| ${g.guildId} | ${g.description} |`),
"",
"Use /add-guild <guild-id> <description> to add more.",
];
return { text: lines.join("\n") };
} catch (e: any) {
return {
text: `Failed to read guild list: ${String(e)}`,
isError: true,
};
}
},
});
}

View File

@@ -0,0 +1,13 @@
/** Extract Discord channel ID from slash command context. */
export function parseDiscordChannelIdFromCommand(cmdCtx: Record<string, unknown>): string | undefined {
// OpenClaw passes channel context in various ways depending on the trigger
const sessionKey = String(cmdCtx.sessionKey ?? "");
const m = sessionKey.match(/:discord:channel:(\d+)$/);
if (m) return m[1];
// Fallback: channelId directly on context
const cid = String(cmdCtx.channelId ?? "");
if (/^\d+$/.test(cid)) return cid;
return undefined;
}

View File

@@ -0,0 +1,70 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore, ChannelMode } from "../core/channel-store.js";
import { parseDiscordChannelIdFromCommand } from "./command-utils.js";
const SWITCHABLE_MODES = new Set<ChannelMode>(["none", "chat", "report"]);
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
type Deps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
};
export function registerSetChannelModeCommand(deps: Deps): void {
const { api, channelStore } = deps;
api.registerCommand({
name: "set-channel-mode",
description: "Set the mode of the current Discord channel: none | chat | report",
acceptsArgs: true,
handler: async (cmdCtx) => {
const raw = (cmdCtx.args || "").trim().toLowerCase() as ChannelMode;
if (!raw) {
return {
text: "Usage: /set-channel-mode <none|chat|report>\n\nModes work and discussion are locked and can only be set via creation tools.",
isError: true,
};
}
if (LOCKED_MODES.has(raw)) {
return {
text: `Mode "${raw}" cannot be set via command — it is locked to its creation tool (create-${raw}-channel or create-discussion-channel).`,
isError: true,
};
}
if (!SWITCHABLE_MODES.has(raw)) {
return {
text: `Unknown mode "${raw}". Valid values: none, chat, report`,
isError: true,
};
}
// Extract channel ID from command context
const channelId = parseDiscordChannelIdFromCommand(cmdCtx);
if (!channelId) {
return {
text: "Could not determine Discord channel ID. Run this command inside a Discord channel.",
isError: true,
};
}
const current = channelStore.getMode(channelId);
if (LOCKED_MODES.has(current)) {
return {
text: `Channel ${channelId} is in locked mode "${current}" and cannot be changed.`,
isError: true,
};
}
try {
channelStore.setMode(channelId, raw);
} catch (err) {
return { text: `Failed: ${String(err)}`, isError: true };
}
return { text: `Channel ${channelId} mode set to "${raw}".` };
},
});
}

View File

@@ -0,0 +1,157 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { IdentityRegistry } from "./identity-registry.js";
const PERM_VIEW_CHANNEL = 1n << 10n;
const PERM_ADMINISTRATOR = 1n << 3n;
function toBigIntPerm(v: unknown): bigint {
if (typeof v === "bigint") return v;
if (typeof v === "number") return BigInt(Math.trunc(v));
if (typeof v === "string" && v.trim()) {
try {
return BigInt(v.trim());
} catch {
return 0n;
}
}
return 0n;
}
function roleOrMemberType(v: unknown): number {
if (typeof v === "number") return v;
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
return 0;
}
async function discordRequest(token: string, method: string, path: string): Promise<{ ok: boolean; status: number; json: any; text: string }> {
const r = await fetch(`https://discord.com/api/v10${path}`, {
method,
headers: {
Authorization: `Bot ${token}`,
"Content-Type": "application/json",
},
});
const text = await r.text();
let json: any = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: r.ok, status: r.status, json, text };
}
function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bigint>, channelOverwrites: any[]): boolean {
const roleIds: string[] = Array.isArray(member?.roles) ? member.roles : [];
let perms = guildRoles.get(guildId) || 0n;
for (const rid of roleIds) perms |= guildRoles.get(rid) || 0n;
if ((perms & PERM_ADMINISTRATOR) !== 0n) return true;
let everyoneAllow = 0n;
let everyoneDeny = 0n;
for (const ow of channelOverwrites) {
if (String(ow?.id || "") === guildId && roleOrMemberType(ow?.type) === 0) {
everyoneAllow = toBigIntPerm(ow?.allow);
everyoneDeny = toBigIntPerm(ow?.deny);
break;
}
}
perms = (perms & ~everyoneDeny) | everyoneAllow;
let roleAllow = 0n;
let roleDeny = 0n;
for (const ow of channelOverwrites) {
if (roleOrMemberType(ow?.type) !== 0) continue;
const id = String(ow?.id || "");
if (id !== guildId && roleIds.includes(id)) {
roleAllow |= toBigIntPerm(ow?.allow);
roleDeny |= toBigIntPerm(ow?.deny);
}
}
perms = (perms & ~roleDeny) | roleAllow;
for (const ow of channelOverwrites) {
if (roleOrMemberType(ow?.type) !== 1) continue;
if (String(ow?.id || "") === String(member?.user?.id || "")) {
const allow = toBigIntPerm(ow?.allow);
const deny = toBigIntPerm(ow?.deny);
perms = (perms & ~deny) | allow;
break;
}
}
return (perms & PERM_VIEW_CHANNEL) !== 0n;
}
function getDiscoveryToken(api: OpenClawPluginApi): string | undefined {
// Prefer moderator bot token from pluginConfig — it has guild member access
const pluginCfg = (api.pluginConfig as Record<string, unknown>) || {};
const moderatorToken = pluginCfg.moderatorBotToken;
if (typeof moderatorToken === "string" && moderatorToken) {
return moderatorToken;
}
// Fall back to any discord account token
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
for (const rec of Object.values(accounts)) {
if (typeof rec?.token === "string" && rec.token) return rec.token;
}
return undefined;
}
/**
* Returns agentIds for all agents visible in the channel, resolved via the identity registry.
*/
export async function fetchVisibleChannelBotAccountIds(
api: OpenClawPluginApi,
channelId: string,
identityRegistry?: IdentityRegistry,
): Promise<string[]> {
const token = getDiscoveryToken(api);
if (!token) return [];
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
if (!ch.ok) return [];
const guildId = String(ch.json?.guild_id || "");
if (!guildId) return [];
const rolesResp = await discordRequest(token, "GET", `/guilds/${guildId}/roles`);
if (!rolesResp.ok) return [];
const rolePerms = new Map<string, bigint>();
for (const r of Array.isArray(rolesResp.json) ? rolesResp.json : []) {
rolePerms.set(String(r?.id || ""), toBigIntPerm(r?.permissions));
}
const members: any[] = [];
let after = "";
while (true) {
const q = new URLSearchParams({ limit: "1000" });
if (after) q.set("after", after);
const mResp = await discordRequest(token, "GET", `/guilds/${guildId}/members?${q.toString()}`);
if (!mResp.ok) return [];
const batch = Array.isArray(mResp.json) ? mResp.json : [];
members.push(...batch);
if (batch.length < 1000) break;
after = String(batch[batch.length - 1]?.user?.id || "");
if (!after) break;
}
const overwrites = Array.isArray(ch.json?.permission_overwrites) ? ch.json.permission_overwrites : [];
const visibleUserIds = members
.filter((m) => canViewChannel(m, guildId, rolePerms, overwrites))
.map((m) => String(m?.user?.id || ""))
.filter(Boolean);
const out = new Set<string>();
if (identityRegistry) {
const discordToAgent = identityRegistry.buildDiscordToAgentMap();
for (const uid of visibleUserIds) {
const aid = discordToAgent.get(uid);
if (aid) out.add(aid);
}
}
return [...out];
}

View File

@@ -0,0 +1,136 @@
import fs from "node:fs";
import path from "node:path";
export type ChannelMode = "none" | "work" | "report" | "discussion" | "chat";
export type TurnManagerState = "disabled" | "dead" | "normal" | "shuffle" | "archived";
/** Modes that cannot be changed once set. */
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
/** Derive turn-manager state from mode + agent count. */
export function deriveTurnManagerState(mode: ChannelMode, agentCount: number, concluded = false): TurnManagerState {
if (mode === "none" || mode === "work") return "disabled";
if (mode === "report") return "dead";
if (mode === "discussion") {
if (concluded) return "archived";
if (agentCount <= 1) return "disabled";
if (agentCount === 2) return "normal";
return "shuffle";
}
if (mode === "chat") {
if (agentCount <= 1) return "disabled";
if (agentCount === 2) return "normal";
return "shuffle";
}
return "disabled";
}
export type DiscussionMeta = {
initiatorAgentId: string;
callbackGuildId: string;
callbackChannelId: string;
concluded: boolean;
};
export type ChannelRecord = {
mode: ChannelMode;
/** For discussion channels: metadata about the discussion. */
discussion?: DiscussionMeta;
};
export class ChannelStore {
private filePath: string;
private records: Record<string, ChannelRecord> = {};
private loaded = false;
constructor(filePath: string) {
this.filePath = filePath;
}
private load(): void {
if (this.loaded) return;
this.loaded = true;
if (!fs.existsSync(this.filePath)) {
this.records = {};
return;
}
try {
const raw = fs.readFileSync(this.filePath, "utf8");
this.records = JSON.parse(raw) ?? {};
} catch {
this.records = {};
}
}
private save(): void {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(this.records, null, 2), "utf8");
}
getMode(channelId: string): ChannelMode {
this.load();
return this.records[channelId]?.mode ?? "none";
}
getRecord(channelId: string): ChannelRecord {
this.load();
return this.records[channelId] ?? { mode: "none" };
}
/**
* Set channel mode. Throws if the channel is currently in a locked mode,
* or if the requested mode is locked (must use setLockedMode instead).
*/
setMode(channelId: string, mode: ChannelMode): void {
this.load();
const current = this.records[channelId]?.mode ?? "none";
if (LOCKED_MODES.has(current)) {
throw new Error(`Channel ${channelId} is in locked mode "${current}" and cannot be changed.`);
}
if (LOCKED_MODES.has(mode)) {
throw new Error(`Mode "${mode}" can only be set at channel creation via the dedicated tool.`);
}
this.records[channelId] = { ...this.records[channelId], mode };
this.save();
}
/**
* Set a locked mode (work or discussion). Only callable from creation tools.
* Throws if the channel already has any mode set.
*/
setLockedMode(channelId: string, mode: ChannelMode, discussion?: DiscussionMeta): void {
this.load();
if (this.records[channelId]) {
throw new Error(`Channel ${channelId} already has mode "${this.records[channelId].mode}".`);
}
const record: ChannelRecord = { mode };
if (discussion) record.discussion = discussion;
this.records[channelId] = record;
this.save();
}
/** Mark a discussion as concluded (sets archived state). */
concludeDiscussion(channelId: string): void {
this.load();
const rec = this.records[channelId];
if (!rec || rec.mode !== "discussion") {
throw new Error(`Channel ${channelId} is not a discussion channel.`);
}
if (!rec.discussion) {
throw new Error(`Channel ${channelId} has no discussion metadata.`);
}
rec.discussion = { ...rec.discussion, concluded: true };
this.save();
}
isLocked(channelId: string): boolean {
this.load();
return LOCKED_MODES.has(this.records[channelId]?.mode ?? "none");
}
listAll(): Array<{ channelId: string } & ChannelRecord> {
this.load();
return Object.entries(this.records).map(([channelId, rec]) => ({ channelId, ...rec }));
}
}

View File

@@ -0,0 +1,93 @@
import fs from "node:fs";
import path from "node:path";
export type IdentityEntry = {
discordUserId: string;
agentId: string;
agentName: string;
};
export class IdentityRegistry {
private filePath: string;
private entries: IdentityEntry[] = [];
private loaded = false;
constructor(filePath: string) {
this.filePath = filePath;
}
private load(): void {
if (this.loaded) return;
this.loaded = true;
if (!fs.existsSync(this.filePath)) {
this.entries = [];
return;
}
try {
const raw = fs.readFileSync(this.filePath, "utf8");
const parsed = JSON.parse(raw);
this.entries = Array.isArray(parsed) ? parsed : [];
} catch {
this.entries = [];
}
}
private save(): void {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(this.entries, null, 2), "utf8");
}
upsert(entry: IdentityEntry): void {
this.load();
const idx = this.entries.findIndex((e) => e.agentId === entry.agentId);
if (idx >= 0) {
this.entries[idx] = entry;
} else {
this.entries.push(entry);
}
this.save();
}
remove(agentId: string): boolean {
this.load();
const before = this.entries.length;
this.entries = this.entries.filter((e) => e.agentId !== agentId);
if (this.entries.length !== before) {
this.save();
return true;
}
return false;
}
findByAgentId(agentId: string): IdentityEntry | undefined {
this.load();
return this.entries.find((e) => e.agentId === agentId);
}
findByDiscordUserId(discordUserId: string): IdentityEntry | undefined {
this.load();
return this.entries.find((e) => e.discordUserId === discordUserId);
}
list(): IdentityEntry[] {
this.load();
return [...this.entries];
}
/** Build a map from discordUserId → agentId for fast lookup. */
buildDiscordToAgentMap(): Map<string, string> {
this.load();
const map = new Map<string, string>();
for (const e of this.entries) map.set(e.discordUserId, e.agentId);
return map;
}
/** Build a map from agentId → discordUserId for fast lookup. */
buildAgentToDiscordMap(): Map<string, string> {
this.load();
const map = new Map<string, string>();
for (const e of this.entries) map.set(e.agentId, e.discordUserId);
return map;
}
}

29
plugin/core/mentions.ts Normal file
View File

@@ -0,0 +1,29 @@
function userIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}
export function extractMentionedUserIds(content: string): string[] {
const regex = /<@!?(\d+)>/g;
const ids: string[] = [];
const seen = new Set<string>();
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const id = match[1];
if (!seen.has(id)) {
seen.add(id);
ids.push(id);
}
}
return ids;
}
export function getModeratorUserIdFromToken(token: string | undefined): string | undefined {
if (!token) return undefined;
return userIdFromToken(token);
}

View File

@@ -0,0 +1,272 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
type Logger = { info: (m: string) => void; warn: (m: string) => void };
export function userIdFromBotToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}
export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const acct = accounts[accountId];
if (!acct?.token || typeof acct.token !== "string") return undefined;
return userIdFromBotToken(acct.token);
}
export type ModeratorMessageResult =
| { ok: true; status: number; channelId: string; messageId?: string }
| { ok: false; status?: number; channelId: string; error: string };
export async function sendModeratorMessage(
token: string,
channelId: string,
content: string,
logger: Logger,
): Promise<ModeratorMessageResult> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST",
headers: {
Authorization: `Bot ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
const text = await r.text();
let json: Record<string, unknown> | null = null;
try {
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
} catch {
json = null;
}
if (!r.ok) {
const error = `discord api error (${r.status}): ${text || "<empty response>"}`;
logger.warn(`dirigent: moderator send failed channel=${channelId} ${error}`);
return { ok: false, status: r.status, channelId, error };
}
const messageId = typeof json?.id === "string" ? json.id : undefined;
logger.info(`dirigent: moderator message sent to channel=${channelId} messageId=${messageId ?? "unknown"}`);
return { ok: true, status: r.status, channelId, messageId };
} catch (err) {
const error = String(err);
logger.warn(`dirigent: moderator send error channel=${channelId}: ${error}`);
return { ok: false, channelId, error };
}
}
/** Delete a Discord message. */
export async function deleteMessage(
token: string,
channelId: string,
messageId: string,
logger: Logger,
): Promise<void> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, {
method: "DELETE",
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) {
logger.warn(`dirigent: deleteMessage failed channel=${channelId} msg=${messageId} status=${r.status}`);
}
} catch (err) {
logger.warn(`dirigent: deleteMessage error: ${String(err)}`);
}
}
/**
* Per-channel last schedule-trigger message ID.
* Stored on globalThis so it survives VM-context hot-reloads.
* Used by sendScheduleTrigger to delete the PREVIOUS trigger when a new one is sent.
*/
const _LAST_TRIGGER_KEY = "_dirigentLastTriggerMsgId";
if (!(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY]) {
(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] = new Map<string, string>();
}
const lastTriggerMsgId: Map<string, string> = (globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] as Map<string, string>;
/**
* Send a schedule-identifier trigger message, then delete the PREVIOUS one for
* this channel (so only the current trigger is visible at any time).
*
* In debugMode the previous message is NOT deleted, leaving a full trigger
* history visible in Discord for inspection.
*/
export async function sendScheduleTrigger(
token: string,
channelId: string,
content: string,
logger: Logger,
debugMode = false,
): Promise<void> {
const prevMsgId = lastTriggerMsgId.get(channelId);
const result = await sendModeratorMessage(token, channelId, content, logger);
if (!result.ok || !result.messageId) return;
if (!debugMode) {
// Track the new message so the NEXT call can delete it
lastTriggerMsgId.set(channelId, result.messageId);
// Delete the previous trigger with a small delay
if (prevMsgId) {
await new Promise((r) => setTimeout(r, 300));
await deleteMessage(token, channelId, prevMsgId, logger);
}
}
// debugMode: don't track, don't delete — every trigger stays in history
}
/** Get the latest message ID in a channel (for use as poll anchor). */
export async function getLatestMessageId(
token: string,
channelId: string,
): Promise<string | undefined> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?limit=1`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return undefined;
const msgs = (await r.json()) as Array<{ id: string }>;
return msgs[0]?.id;
} catch {
return undefined;
}
}
type DiscordMessage = { id: string; author: { id: string }; content: string };
/**
* Poll the channel until any message from agentDiscordUserId with id > anchorId
* appears. The anchor is set in before_model_resolve just before the LLM call,
* so any agent message after it must belong to this turn.
*
* Uses exponential back-off starting at initialPollMs, doubling each miss,
* capped at maxPollMs. Times out after timeoutMs (default 30 s) to avoid
* getting permanently stuck when the agent's Discord message is never delivered.
*
* @returns { matched: true } when found, { interrupted: true } when aborted,
* { matched: false, interrupted: false } on timeout.
*/
export async function pollForTailMatch(opts: {
token: string;
channelId: string;
anchorId: string;
agentDiscordUserId: string;
/** Initial poll interval in ms (default 800). */
initialPollMs?: number;
/** Maximum poll interval in ms (default 8 000). */
maxPollMs?: number;
/** Give up and return after this many ms (default 30 000). */
timeoutMs?: number;
/** Callback checked before each poll; if true, polling is aborted. */
isInterrupted?: () => boolean;
}): Promise<{ matched: boolean; interrupted: boolean }> {
const {
token, channelId, anchorId, agentDiscordUserId,
initialPollMs = 800,
maxPollMs = 8_000,
timeoutMs = 30_000,
isInterrupted = () => false,
} = opts;
let interval = initialPollMs;
const deadline = Date.now() + timeoutMs;
while (true) {
if (isInterrupted()) return { matched: false, interrupted: true };
if (Date.now() >= deadline) return { matched: false, interrupted: false };
try {
const r = await fetch(
`https://discord.com/api/v10/channels/${channelId}/messages?limit=20`,
{ headers: { Authorization: `Bot ${token}` } },
);
if (r.ok) {
const msgs = (await r.json()) as DiscordMessage[];
const found = msgs.some(
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
);
if (found) return { matched: true, interrupted: false };
}
} catch {
// ignore transient errors, keep polling
}
await new Promise((r) => setTimeout(r, interval));
// Exponential back-off, capped at maxPollMs
interval = Math.min(interval * 2, maxPollMs);
}
}
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
export async function createDiscordChannel(opts: {
token: string;
guildId: string;
name: string;
/** Permission overwrites: [{id, type (0=role/1=member), allow, deny}] */
permissionOverwrites?: Array<{ id: string; type: number; allow?: string; deny?: string }>;
logger: Logger;
}): Promise<string> {
const { token, guildId, name, permissionOverwrites = [], logger } = opts;
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
method: "POST",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, type: 0, permission_overwrites: permissionOverwrites }),
});
const json = (await r.json()) as Record<string, unknown>;
if (!r.ok) {
const err = `Discord channel create failed (${r.status}): ${JSON.stringify(json)}`;
logger.warn(`dirigent: ${err}`);
throw new Error(err);
}
const channelId = json.id as string;
logger.info(`dirigent: created Discord channel ${name} id=${channelId} in guild=${guildId}`);
return channelId;
}
/** Fetch guilds where the moderator bot has admin permissions. */
export async function fetchAdminGuilds(token: string): Promise<Array<{ id: string; name: string }>> {
const r = await fetch("https://discord.com/api/v10/users/@me/guilds", {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return [];
const guilds = (await r.json()) as Array<{ id: string; name: string; permissions: string }>;
const ADMIN = 8n;
return guilds.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN);
}
/** Fetch private group channels in a guild visible to the moderator bot. */
export async function fetchGuildChannels(
token: string,
guildId: string,
): Promise<Array<{ id: string; name: string; type: number }>> {
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return [];
const channels = (await r.json()) as Array<{ id: string; name: string; type: number }>;
// type 0 = GUILD_TEXT; filter to text channels only (group private channels are type 0)
return channels.filter((c) => c.type === 0);
}
/** Get bot's own Discord user ID from token. */
export function getBotUserIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
import type { IdentityRegistry } from "./identity-registry.js";
type EgoData = {
columns?: string[];
agentScope?: Record<string, Record<string, string>>;
};
/**
* Scan padded-cell's ego.json and upsert agent Discord IDs into the identity registry.
* Only runs if ego.json contains the "discord-id" column — otherwise treated as absent.
*
* @returns number of entries upserted, or -1 if padded-cell is not detected.
*/
export function scanPaddedCell(
registry: IdentityRegistry,
openclawDir: string,
logger: { info: (m: string) => void; warn: (m: string) => void },
): number {
const egoPath = path.join(openclawDir, "ego.json");
if (!fs.existsSync(egoPath)) {
logger.info("dirigent: padded-cell ego.json not found — skipping auto-registration");
return -1;
}
let ego: EgoData;
try {
ego = JSON.parse(fs.readFileSync(egoPath, "utf8"));
} catch (e) {
logger.warn(`dirigent: failed to parse ego.json: ${String(e)}`);
return -1;
}
if (!Array.isArray(ego.columns) || !ego.columns.includes("discord-id")) {
logger.info('dirigent: ego.json does not have "discord-id" column — padded-cell not configured for Discord, skipping');
return -1;
}
const agentScope = ego.agentScope ?? {};
let count = 0;
for (const [agentId, fields] of Object.entries(agentScope)) {
const discordUserId = fields["discord-id"];
if (!discordUserId || typeof discordUserId !== "string") continue;
const existing = registry.findByAgentId(agentId);
registry.upsert({
agentId,
discordUserId,
agentName: existing?.agentName ?? agentId,
});
count++;
}
logger.info(`dirigent: padded-cell scan complete — upserted ${count} identity entries`);
return count;
}

View File

@@ -0,0 +1,119 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
let noReplyProcess: ChildProcess | null = null;
const LOCK_FILE = path.join(os.tmpdir(), "dirigent-sidecar.lock");
function readLock(): { pid: number } | null {
try {
const raw = fs.readFileSync(LOCK_FILE, "utf8").trim();
return { pid: Number(raw) };
} catch {
return null;
}
}
function writeLock(pid: number): void {
try { fs.writeFileSync(LOCK_FILE, String(pid)); } catch { /* ignore */ }
}
function clearLock(): void {
try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
}
function isLockHeld(): boolean {
const lock = readLock();
if (!lock) return false;
try {
process.kill(lock.pid, 0);
return true;
} catch {
return false;
}
}
export function startSideCar(
logger: { info: (m: string) => void; warn: (m: string) => void },
pluginDir: string,
port = 8787,
moderatorToken?: string,
pluginApiToken?: string,
gatewayPort?: number,
debugMode?: boolean,
): void {
logger.info(`dirigent: startSideCar called, pluginDir=${pluginDir}`);
if (noReplyProcess) {
logger.info("dirigent: no-reply API already running (local ref), skipping");
return;
}
if (isLockHeld()) {
logger.info("dirigent: no-reply API already running (lock file), skipping");
return;
}
// services/main.mjs lives alongside the plugin directory in the distribution
const serverPath = path.resolve(pluginDir, "services", "main.mjs");
logger.info(`dirigent: resolved serverPath=${serverPath}`);
if (!fs.existsSync(serverPath)) {
logger.warn(`dirigent: services/main.mjs not found at ${serverPath}, skipping`);
return;
}
logger.info("dirigent: services/main.mjs found, spawning process...");
// Build plugin API URL from gateway port, or use a default
const pluginApiUrl = gatewayPort
? `http://127.0.0.1:${gatewayPort}`
: "http://127.0.0.1:18789";
const env: NodeJS.ProcessEnv = {
...process.env,
SERVICES_PORT: String(port),
PLUGIN_API_URL: pluginApiUrl,
};
if (moderatorToken) {
env.MODERATOR_TOKEN = moderatorToken;
}
if (pluginApiToken) {
env.PLUGIN_API_TOKEN = pluginApiToken;
}
if (debugMode !== undefined) {
env.DEBUG_MODE = debugMode ? "true" : "false";
}
noReplyProcess = spawn(process.execPath, [serverPath], {
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
if (noReplyProcess.pid) {
writeLock(noReplyProcess.pid);
}
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: services: ${d.toString().trim()}`));
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: services: ${d.toString().trim()}`));
noReplyProcess.on("exit", (code, signal) => {
logger.info(`dirigent: services exited (code=${code}, signal=${signal})`);
clearLock();
noReplyProcess = null;
});
logger.info(`dirigent: services started (pid=${noReplyProcess.pid}, port=${port})`);
}
export function stopSideCar(logger: { info: (m: string) => void }): void {
if (!noReplyProcess) return;
logger.info("dirigent: stopping sidecar");
noReplyProcess.kill("SIGTERM");
noReplyProcess = null;
clearLock();
}

272
plugin/hooks/agent-end.ts Normal file
View File

@@ -0,0 +1,272 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { parseDiscordChannelId } from "./before-model-resolve.js";
import {
isCurrentSpeaker,
getAnchor,
advanceSpeaker,
wakeFromDormant,
isDormant,
hasSpeakers,
getDebugInfo,
isTurnPending,
clearTurnPending,
consumeBlockedPending,
type SpeakerEntry,
} from "../turn-manager.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
import { pollForTailMatch, sendScheduleTrigger } from "../core/moderator-discord.js";
/**
* Process-level deduplication for agent_end events.
* OpenClaw hot-reloads plugin modules (re-imports), stacking duplicate handlers.
* Using globalThis ensures the dedup Set survives module reloads and is shared
* by all handler instances in the same process.
*/
const _AGENT_END_DEDUP_KEY = "_dirigentProcessedAgentEndRunIds";
if (!(globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY]) {
(globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] = new Set<string>();
}
const processedAgentEndRunIds: Set<string> = (globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] as Set<string>;
/**
* Per-channel advance lock: prevents two concurrent agent_end handler instances
* (from different VM contexts with different runIds) from both calling advanceSpeaker
* for the same channel at the same time, which would double-advance the speaker index.
*/
const _ADVANCE_LOCK_KEY = "_dirigentAdvancingChannels";
if (!(globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY]) {
(globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY] = new Set<string>();
}
const advancingChannels: Set<string> = (globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY] as Set<string>;
/** Extract plain text from agent_end event.messages last assistant entry. */
function extractFinalText(messages: unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i] as Record<string, unknown> | null;
if (!msg || msg.role !== "assistant") continue;
const content = msg.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
let text = "";
for (const part of content) {
const p = part as Record<string, unknown>;
if (p?.type === "text" && typeof p.text === "string") text += p.text;
}
return text;
}
break;
}
return "";
}
function isEmptyTurn(text: string): boolean {
const t = text.trim();
if (t === "") return true;
// Check if the last non-empty line is NO or NO_REPLY (agents often write
// explanatory text before the final answer on the last line).
const lines = t.split("\n").map((l) => l.trim()).filter((l) => l !== "");
const last = lines[lines.length - 1] ?? "";
return /^NO$/i.test(last) || /^NO_REPLY$/i.test(last);
}
export type AgentEndDeps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
debugMode: boolean;
/** Called when discussion channel enters dormant — to send idle reminder. */
onDiscussionDormant?: (channelId: string) => Promise<void>;
};
/** Exposed so message-received can interrupt an in-progress tail-match wait. */
export type InterruptFn = (channelId: string) => void;
export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, onDiscussionDormant } = deps;
const interruptedChannels = new Set<string>();
function interrupt(channelId: string): void {
interruptedChannels.add(channelId);
setTimeout(() => interruptedChannels.delete(channelId), 5000);
}
async function buildSpeakerList(channelId: string): Promise<SpeakerEntry[]> {
if (!moderatorBotToken) return [];
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const result: SpeakerEntry[] = [];
for (const agentId of agentIds) {
const identity = identityRegistry.findByAgentId(agentId);
if (identity) result.push({ agentId, discordUserId: identity.discordUserId });
}
return result;
}
async function triggerNextSpeaker(channelId: string, next: SpeakerEntry): Promise<void> {
if (!moderatorBotToken) return;
const msg = `<@${next.discordUserId}>${scheduleIdentifier}`;
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
api.logger.info(`dirigent: triggered next speaker agentId=${next.agentId} channel=${channelId}`);
}
api.on("agent_end", async (event, ctx) => {
try {
// Deduplicate: skip if this runId was already processed by another handler
// instance (can happen when OpenClaw hot-reloads the plugin in the same process).
const runId = (event as Record<string, unknown>).runId as string | undefined;
if (runId) {
if (processedAgentEndRunIds.has(runId)) return;
processedAgentEndRunIds.add(runId);
// Evict old entries to prevent unbounded growth (keep last 500)
if (processedAgentEndRunIds.size > 500) {
const oldest = processedAgentEndRunIds.values().next().value;
if (oldest) processedAgentEndRunIds.delete(oldest);
}
}
const sessionKey = ctx.sessionKey;
if (!sessionKey) return;
const channelId = parseDiscordChannelId(sessionKey);
if (!channelId) return;
const mode = channelStore.getMode(channelId);
if (mode === "none" || mode === "work" || mode === "report") return;
if (!hasSpeakers(channelId)) return;
const agentId = ctx.agentId;
if (!agentId) return;
// Extract final text early so we can drain blocked_pending at every early-return point.
// This prevents the counter from staying inflated when stale NO_REPLYs are discarded
// by !isCurrentSpeaker or !isTurnPending without reaching consumeBlockedPending.
const messages = Array.isArray((event as Record<string, unknown>).messages)
? ((event as Record<string, unknown>).messages as unknown[])
: [];
const finalText = extractFinalText(messages);
const empty = isEmptyTurn(finalText);
if (!isCurrentSpeaker(channelId, agentId)) {
// Drain blocked_pending for non-speaker stale NO_REPLYs. Without this, suppressions
// that happen while this agent is not the current speaker inflate the counter and cause
// its subsequent real empty turn to be misidentified as stale.
if (empty) consumeBlockedPending(channelId, agentId);
return;
}
// Only process agent_ends for turns that were explicitly started by before_model_resolve.
// This prevents stale NO_REPLY completions (from initial suppression) from being counted.
if (!isTurnPending(channelId, agentId)) {
api.logger.info(`dirigent: agent_end skipping stale turn agentId=${agentId} channel=${channelId}`);
// Also drain here: stale may arrive after clearTurnPending but before markTurnStarted.
if (empty) consumeBlockedPending(channelId, agentId);
return;
}
// Consume a blocked-pending slot only for self-wakeup stales: NO_REPLY completions
// from suppressed self-wakeup before_model_resolve calls that fire while the agent
// is current speaker with a turn already in progress.
if (empty && consumeBlockedPending(channelId, agentId)) {
api.logger.info(`dirigent: agent_end skipping blocked-pending NO_REPLY agentId=${agentId} channel=${channelId}`);
return;
}
api.logger.info(
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
);
if (!empty) {
// Real turn: wait for Discord delivery before triggering next speaker.
// Anchor was set in before_model_resolve just before the LLM call, so any
// message from the agent after the anchor must be from this turn.
// NOTE: clearTurnPending is intentionally deferred until after pollForTailMatch
// returns. While waiting, isTurnPending remains true so that any re-trigger of
// this agent is correctly treated as a self-wakeup (suppressed), preventing it
// from starting a second real turn during the tail-match window.
const identity = identityRegistry.findByAgentId(agentId);
if (identity && moderatorBotToken) {
const anchorId = getAnchor(channelId, agentId) ?? "0";
const { matched: _matched, interrupted } = await pollForTailMatch({
token: moderatorBotToken,
channelId,
anchorId,
agentDiscordUserId: identity.discordUserId,
isInterrupted: () => interruptedChannels.has(channelId),
});
if (interrupted) {
if (isDormant(channelId)) {
// Channel is dormant: a new external message woke it — restart from first speaker
api.logger.info(`dirigent: tail-match interrupted (dormant) channel=${channelId} — waking`);
clearTurnPending(channelId, agentId);
const first = wakeFromDormant(channelId);
if (first) await triggerNextSpeaker(channelId, first);
return;
}
// Not dormant: interrupt was a spurious trigger (e.g. moderator bot message).
// Fall through to normal advance so the turn cycle continues correctly.
api.logger.info(`dirigent: tail-match interrupted (non-dormant) channel=${channelId} — advancing normally`);
}
}
}
// Guard against concurrent advanceSpeaker calls for the same channel.
// Two VM context instances may both reach this point with different runIds
// (when the dedup Set doesn't catch them); only the first should advance.
if (advancingChannels.has(channelId)) {
api.logger.info(`dirigent: agent_end advance already in progress, skipping channel=${channelId} agentId=${agentId}`);
return;
}
advancingChannels.add(channelId);
let next: ReturnType<typeof import("../turn-manager.js").getCurrentSpeaker> | null = null;
let enteredDormant = false;
try {
// Determine shuffle mode from current list size
const debugBefore = getDebugInfo(channelId);
const currentListSize = debugBefore.exists ? (debugBefore.speakerList?.length ?? 0) : 0;
const isShuffle = currentListSize > 2;
// In shuffle mode, pass agentId as previousLastAgentId so new list avoids it as first
const previousLastAgentId = isShuffle ? agentId : undefined;
({ next, enteredDormant } = await advanceSpeaker(
channelId,
agentId,
empty,
() => buildSpeakerList(channelId),
previousLastAgentId,
));
} finally {
advancingChannels.delete(channelId);
}
// Clear turn pending AFTER advanceSpeaker completes. This ensures isTurnPending
// remains true during the async rebuildFn window at cycle boundaries, preventing
// re-triggers from starting a second real turn while currentIndex is still at the
// outgoing speaker's position.
clearTurnPending(channelId, agentId);
if (enteredDormant) {
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
if (mode === "discussion") {
await onDiscussionDormant?.(channelId).catch((err) => {
api.logger.warn(`dirigent: onDiscussionDormant failed: ${String(err)}`);
});
}
return;
}
if (next) await triggerNextSpeaker(channelId, next);
} catch (err) {
api.logger.warn(`dirigent: agent_end hook error: ${String(err)}`);
}
});
return interrupt;
}

View File

@@ -0,0 +1,165 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { isCurrentSpeaker, isTurnPending, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { getLatestMessageId, sendScheduleTrigger } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
export function parseDiscordChannelId(sessionKey: string): string | undefined {
const m = sessionKey.match(/:discord:channel:(\d+)$/);
return m?.[1];
}
type Deps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
debugMode: boolean;
noReplyProvider: string;
noReplyModel: string;
};
/**
* Process-level deduplication for before_model_resolve events.
* Uses a WeakSet keyed on the event object — works when OpenClaw passes
* the same event reference to all stacked handlers (hot-reload scenario).
* Stored on globalThis so it persists across module reloads.
*/
const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents";
if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] = new WeakSet<object>();
}
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
export function registerBeforeModelResolveHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, noReplyProvider, noReplyModel } = deps;
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
const initializingChannels = getInitializingChannels();
api.on("before_model_resolve", async (event, ctx) => {
// Deduplicate: if another handler instance already processed this event
// object, skip. Prevents double-counting from hot-reload stacked handlers.
const eventObj = event as object;
if (processedBeforeModelResolveEvents.has(eventObj)) return;
processedBeforeModelResolveEvents.add(eventObj);
const sessionKey = ctx.sessionKey;
if (!sessionKey) return;
// Only handle Discord group channel sessions
const channelId = parseDiscordChannelId(sessionKey);
if (!channelId) return;
const mode = channelStore.getMode(channelId);
// dead/report mode: suppress all via no-reply model
if (mode === "report" || mode === "dead" as string) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// concluded discussion: suppress via no-reply model
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded) {
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
}
// disabled modes: no turn management
if (mode === "none" || mode === "work") return;
// chat / discussion (active): check turn
const agentId = ctx.agentId;
if (!agentId) return;
// If speaker list not yet loaded, initialize it now
if (!hasSpeakers(channelId)) {
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
// incrementBlockedPending so agent_end knows to expect a stale NO_REPLY completion later
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
initializingChannels.add(channelId);
try {
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const speakers: SpeakerEntry[] = agentIds
.map((aid) => {
const entry = identityRegistry.findByAgentId(aid);
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
})
.filter((s): s is SpeakerEntry => s !== null);
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
// If this agent is NOT the first speaker, trigger first speaker and suppress self
if (first.agentId !== agentId && moderatorBotToken) {
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// Fall through — this agent IS the first speaker
} else {
// No registered agents visible — let everyone respond freely
return;
}
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
return;
} finally {
initializingChannels.delete(channelId);
}
}
// Channel is dormant: suppress via no-reply model
if (isDormant(channelId)) {
api.logger.info(`dirigent: before_model_resolve suppressing dormant agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
if (!isCurrentSpeaker(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve suppressing non-speaker agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// If a turn is already in progress for this agent, this is a duplicate wakeup
// (e.g. agent woke itself via a message-tool send). Suppress it.
if (isTurnPending(channelId, agentId)) {
api.logger.info(`dirigent: before_model_resolve turn already in progress, suppressing self-wakeup agentId=${agentId} channel=${channelId}`);
incrementBlockedPending(channelId, agentId);
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
}
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
markTurnStarted(channelId, agentId);
// Current speaker: record anchor message ID for tail-match polling
if (moderatorBotToken) {
try {
const anchorId = await getLatestMessageId(moderatorBotToken, channelId);
if (anchorId) {
setAnchor(channelId, agentId, anchorId);
api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`);
}
} catch (err) {
api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`);
}
}
// Verify agent has a known Discord user ID (needed for tail-match later)
const identity = identityRegistry.findByAgentId(agentId);
if (!identity) {
api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`);
}
});
}

View File

@@ -0,0 +1,128 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { parseDiscordChannelId } from "./before-model-resolve.js";
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { sendScheduleTrigger, userIdFromBotToken } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
import type { InterruptFn } from "./agent-end.js";
type Deps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
interruptTailMatch: InterruptFn;
debugMode: boolean;
/**
* When true, the moderator service handles wake-from-dormant and
* interrupt-tail-match via HTTP callback. This hook only runs speaker-list
* initialization in that case.
*/
moderatorHandlesMessages?: boolean;
};
export function registerMessageReceivedHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch, debugMode, moderatorHandlesMessages } = deps;
const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined;
api.on("message_received", async (event, ctx) => {
try {
const e = event as Record<string, unknown>;
const c = ctx as Record<string, unknown>;
// Extract Discord channel ID from ctx or event metadata
let channelId: string | undefined;
if (typeof c.channelId === "string") {
const bare = c.channelId.match(/^(\d+)$/)?.[1] ?? c.channelId.match(/:(\d+)$/)?.[1];
if (bare) channelId = bare;
}
if (!channelId && typeof c.sessionKey === "string") {
channelId = parseDiscordChannelId(c.sessionKey);
}
if (!channelId) {
const metadata = e.metadata as Record<string, unknown> | undefined;
const to = String(metadata?.to ?? metadata?.originatingTo ?? "");
const toMatch = to.match(/:(\d+)$/);
if (toMatch) channelId = toMatch[1];
}
if (!channelId) {
const metadata = e.metadata as Record<string, unknown> | undefined;
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
const raw = String(convInfo?.channel_id ?? metadata?.channelId ?? "");
if (/^\d+$/.test(raw)) channelId = raw;
}
if (!channelId) return;
const mode = channelStore.getMode(channelId);
if (mode === "report") return;
if (mode === "none" || mode === "work") return;
// ── Speaker-list initialization (always runs, even with moderator service) ──
const initializingChannels = getInitializingChannels();
if (!hasSpeakers(channelId) && moderatorBotToken) {
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
return;
}
initializingChannels.add(channelId);
try {
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
const speakers: SpeakerEntry[] = agentIds
.map((aid) => {
const entry = identityRegistry.findByAgentId(aid);
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
})
.filter((s): s is SpeakerEntry => s !== null);
if (speakers.length > 0) {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`);
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
return;
}
} finally {
initializingChannels.delete(channelId);
}
}
// ── Wake / interrupt (skipped when moderator service handles it via HTTP callback) ──
if (moderatorHandlesMessages) return;
const senderId = String(
(e.metadata as Record<string, unknown>)?.senderId ??
(e.metadata as Record<string, unknown>)?.sender_id ??
e.from ?? "",
);
const currentSpeakerIsThisSender = (() => {
if (!senderId) return false;
const entry = identityRegistry.findByDiscordUserId(senderId);
if (!entry) return false;
return isCurrentSpeaker(channelId!, entry.agentId);
})();
if (!currentSpeakerIsThisSender) {
if (senderId !== moderatorBotUserId) {
interruptTailMatch(channelId);
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
}
if (isDormant(channelId) && moderatorBotToken && senderId !== moderatorBotUserId) {
const first = wakeFromDormant(channelId);
if (first) {
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
}
}
}
} catch (err) {
api.logger.warn(`dirigent: message_received hook error: ${String(err)}`);
}
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,22 @@
{
"id": "whispergate",
"name": "WhisperGate",
"version": "0.1.0",
"description": "Rule-based no-reply gate with provider/model override",
"id": "dirigent",
"name": "Dirigent",
"version": "0.3.0",
"description": "Rule-based no-reply gate with provider/model override and turn management",
"entry": "./index.ts",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean", "default": true },
"discordOnly": { "type": "boolean", "default": true },
"listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" },
"humanList": { "type": "array", "items": { "type": "string" }, "default": [] },
"agentList": { "type": "array", "items": { "type": "string" }, "default": [] },
"channelPoliciesFile": { "type": "string", "default": "~/.openclaw/whispergate-channel-policies.json" },
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
"noReplyProvider": { "type": "string" },
"noReplyModel": { "type": "string" },
"enableDiscordControlTool": { "type": "boolean", "default": true },
"enableWhispergatePolicyTool": { "type": "boolean", "default": true },
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
"discordControlApiToken": { "type": "string" },
"discordControlCallerId": { "type": "string" },
"enableDebugLogs": { "type": "boolean", "default": false },
"debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] }
"moderatorBotToken": { "type": "string" },
"scheduleIdentifier": { "type": "string", "default": "➡️" },
"identityFilePath": { "type": "string" },
"channelStoreFilePath": { "type": "string" },
"debugMode": { "type": "boolean", "default": false },
"noReplyProvider": { "type": "string", "default": "dirigent" },
"noReplyModel": { "type": "string", "default": "no-reply" },
"sideCarPort": { "type": "number", "default": 8787 }
},
"required": ["noReplyProvider", "noReplyModel"]
"required": []
}
}

View File

@@ -1,9 +1,9 @@
{
"name": "whispergate-plugin",
"version": "0.1.0",
"name": "dirigent-plugin",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "WhisperGate OpenClaw plugin",
"description": "Dirigent OpenClaw plugin",
"scripts": {
"check": "node ../scripts/check-plugin-files.mjs",
"check:rules": "node ../scripts/validate-rules.mjs"

View File

@@ -1,134 +0,0 @@
export type WhisperGateConfig = {
enabled?: boolean;
discordOnly?: boolean;
listMode?: "human-list" | "agent-list";
humanList?: string[];
agentList?: string[];
channelPoliciesFile?: string;
// backward compatibility
bypassUserIds?: string[];
endSymbols?: string[];
noReplyProvider: string;
noReplyModel: string;
/** Discord bot token for the moderator bot (used for turn handoff messages) */
moderatorBotToken?: string;
};
export type ChannelPolicy = {
listMode?: "human-list" | "agent-list";
humanList?: string[];
agentList?: string[];
endSymbols?: string[];
};
export type Decision = {
shouldUseNoReply: boolean;
shouldInjectEndMarkerPrompt: boolean;
reason: string;
};
/**
* Strip trailing OpenClaw metadata blocks from the prompt to get the actual message content.
* The prompt format is: [prepend instruction]\n\n[message]\n\nConversation info (untrusted metadata):\n```json\n{...}\n```\n\nSender (untrusted metadata):\n```json\n{...}\n```
*/
function stripTrailingMetadata(input: string): string {
// Remove all trailing "XXX (untrusted metadata):\n```json\n...\n```" blocks
let text = input;
// eslint-disable-next-line no-constant-condition
while (true) {
const m = text.match(/\n*[A-Z][^\n]*\(untrusted metadata\):\s*```json\s*[\s\S]*?```\s*$/);
if (!m) break;
text = text.slice(0, text.length - m[0].length);
}
return text;
}
function getLastChar(input: string): string {
const t = stripTrailingMetadata(input).trim();
if (!t.length) return "";
// Use Array.from to handle multi-byte characters (emoji, surrogate pairs)
const chars = Array.from(t);
return chars[chars.length - 1] || "";
}
export function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record<string, ChannelPolicy>) {
const globalMode = config.listMode || "human-list";
const globalHuman = config.humanList || config.bypassUserIds || [];
const globalAgent = config.agentList || [];
const globalEnd = config.endSymbols || ["🔚"];
if (!channelId) {
return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd };
}
const cp = channelPolicies || {};
const scoped = cp[channelId];
if (!scoped) {
return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd };
}
return {
listMode: scoped.listMode || globalMode,
humanList: scoped.humanList || globalHuman,
agentList: scoped.agentList || globalAgent,
endSymbols: scoped.endSymbols || globalEnd,
};
}
export function evaluateDecision(params: {
config: WhisperGateConfig;
channel?: string;
channelId?: string;
channelPolicies?: Record<string, ChannelPolicy>;
senderId?: string;
content?: string;
}): Decision {
const { config } = params;
if (config.enabled === false) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "disabled" };
}
const channel = (params.channel || "").toLowerCase();
if (config.discordOnly !== false && channel !== "discord") {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" };
}
// DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId),
// this is a DM session where untrusted metadata is not injected. Always allow through.
if (!params.senderId && !params.channelId) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "dm_no_metadata_bypass" };
}
const policy = resolvePolicy(config, params.channelId, params.channelPolicies);
const mode = policy.listMode;
const humanList = policy.humanList;
const agentList = policy.agentList;
const senderId = params.senderId || "";
const inHumanList = !!senderId && humanList.includes(senderId);
const inAgentList = !!senderId && agentList.includes(senderId);
const lastChar = getLastChar(params.content || "");
const hasEnd = !!lastChar && policy.endSymbols.includes(lastChar);
if (mode === "human-list") {
if (inHumanList) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "human_list_sender" };
}
if (hasEnd) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` };
}
return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "rule_match_no_end_symbol" };
}
// agent-list mode: listed senders require end symbol; others bypass requirement.
if (!inAgentList) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "non_agent_list_sender" };
}
if (hasEnd) {
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` };
}
return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "agent_list_missing_end_symbol" };
}

View File

@@ -0,0 +1,353 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { createDiscordChannel, getBotUserIdFromToken } from "../core/moderator-discord.js";
import { setSpeakerList } from "../turn-manager.js";
type ToolDeps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
/** Called by create-discussion-channel to initialize the discussion. */
onDiscussionCreate?: (params: {
channelId: string;
guildId: string;
initiatorAgentId: string;
callbackGuildId: string;
callbackChannelId: string;
discussionGuide: string;
participants: string[];
}) => Promise<void>;
};
function getGuildIdFromSessionKey(sessionKey: string): string | undefined {
// sessionKey doesn't encode guild — it's not available directly.
// Guild is passed explicitly by the agent.
return undefined;
}
function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined {
const m = sessionKey.match(/:discord:channel:(\d+)$/);
return m?.[1];
}
function textResult(text: string) {
return { content: [{ type: "text" as const, text }], details: undefined };
}
function errorResult(text: string) {
return { content: [{ type: "text" as const, text }], details: { error: true } };
}
export function registerDirigentTools(deps: ToolDeps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps;
// ───────────────────────────────────────────────
// dirigent-register
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "dirigent-register",
label: "Dirigent Register",
description: "Register or update this agent's Discord user ID in Dirigent's identity registry.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
discordUserId: { type: "string", description: "The agent's Discord user ID" },
agentName: { type: "string", description: "Display name (optional, defaults to agentId)" },
},
required: ["discordUserId"],
},
execute: async (_toolCallId: string, params: unknown) => {
const agentId = ctx?.agentId;
if (!agentId) return errorResult("Cannot resolve agentId from session context");
const p = params as { discordUserId: string; agentName?: string };
identityRegistry.upsert({
agentId,
discordUserId: p.discordUserId,
agentName: p.agentName ?? agentId,
});
return textResult(`Registered: agentId=${agentId} discordUserId=${p.discordUserId}`);
},
}));
// ───────────────────────────────────────────────
// Helper: create channel + set mode
// ───────────────────────────────────────────────
async function createManagedChannel(opts: {
guildId: string;
name: string;
memberDiscordIds: string[];
mode: "chat" | "report" | "work";
}): Promise<{ ok: boolean; channelId?: string; error?: string }> {
if (!moderatorBotToken) return { ok: false, error: "moderatorBotToken not configured" };
const botId = getBotUserIdFromToken(moderatorBotToken);
const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [
{ id: opts.guildId, type: 0, deny: "1024" }, // deny everyone
];
if (botId) overwrites.push({ id: botId, type: 1, allow: "1024" });
for (const uid of opts.memberDiscordIds) {
if (uid) overwrites.push({ id: uid, type: 1, allow: "1024" });
}
let channelId: string;
try {
channelId = await createDiscordChannel({
token: moderatorBotToken,
guildId: opts.guildId,
name: opts.name,
permissionOverwrites: overwrites,
logger: api.logger,
});
} catch (err) {
return { ok: false, error: String(err) };
}
try {
channelStore.setLockedMode(channelId, opts.mode);
} catch {
channelStore.setMode(channelId, opts.mode);
}
return { ok: true, channelId };
}
// ───────────────────────────────────────────────
// create-chat-channel
// ───────────────────────────────────────────────
api.registerTool({
name: "create-chat-channel",
label: "Create Chat Channel",
description: "Create a new private Discord channel in the specified guild with mode=chat.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
guildId: { type: "string", description: "Guild ID to create the channel in" },
name: { type: "string", description: "Channel name" },
participants: {
type: "array", items: { type: "string" },
description: "Discord user IDs to add (moderator bot always added)",
},
},
required: ["guildId", "name"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { guildId: string; name: string; participants?: string[] };
const result = await createManagedChannel({
guildId: p.guildId, name: p.name,
memberDiscordIds: p.participants ?? [],
mode: "chat",
});
if (!result.ok) return errorResult(`Failed: ${result.error}`);
return textResult(`Created chat channel: ${result.channelId}`);
},
});
// ───────────────────────────────────────────────
// create-report-channel
// ───────────────────────────────────────────────
api.registerTool({
name: "create-report-channel",
label: "Create Report Channel",
description: "Create a new private Discord channel with mode=report. Agents can post to it but are not woken by messages.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
guildId: { type: "string", description: "Guild ID" },
name: { type: "string", description: "Channel name" },
members: { type: "array", items: { type: "string" }, description: "Discord user IDs to add" },
},
required: ["guildId", "name"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { guildId: string; name: string; members?: string[] };
const result = await createManagedChannel({
guildId: p.guildId, name: p.name,
memberDiscordIds: p.members ?? [],
mode: "report",
});
if (!result.ok) return errorResult(`Failed: ${result.error}`);
return textResult(`Created report channel: ${result.channelId}`);
},
});
// ───────────────────────────────────────────────
// create-work-channel
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "create-work-channel",
label: "Create Work Channel",
description: "Create a new private Discord workspace channel with mode=work (turn-manager disabled, mode locked).",
parameters: {
type: "object",
additionalProperties: false,
properties: {
guildId: { type: "string", description: "Guild ID" },
name: { type: "string", description: "Channel name" },
members: { type: "array", items: { type: "string" }, description: "Additional Discord user IDs to add" },
},
required: ["guildId", "name"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { guildId: string; name: string; members?: string[] };
// Include calling agent's Discord ID if known
const callerDiscordId = ctx?.agentId ? identityRegistry.findByAgentId(ctx.agentId)?.discordUserId : undefined;
const members = [...(p.members ?? [])];
if (callerDiscordId && !members.includes(callerDiscordId)) members.push(callerDiscordId);
const result = await createManagedChannel({
guildId: p.guildId, name: p.name,
memberDiscordIds: members,
mode: "work",
});
if (!result.ok) return errorResult(`Failed: ${result.error}`);
return textResult(`Created work channel: ${result.channelId}`);
},
}));
// ───────────────────────────────────────────────
// create-discussion-channel
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "create-discussion-channel",
label: "Create Discussion Channel",
description: "Create a structured discussion channel between agents. The calling agent becomes the initiator.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
callbackGuildId: { type: "string", description: "Guild ID of your current channel (for callback after discussion)" },
callbackChannelId: { type: "string", description: "Channel ID to post the summary to after discussion completes" },
name: { type: "string", description: "Discussion channel name" },
discussionGuide: { type: "string", description: "Topic, goals, and completion criteria for the discussion" },
participants: { type: "array", items: { type: "string" }, description: "Discord user IDs of participating agents" },
},
required: ["callbackGuildId", "callbackChannelId", "name", "discussionGuide", "participants"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as {
callbackGuildId: string;
callbackChannelId: string;
name: string;
discussionGuide: string;
participants: string[];
};
const initiatorAgentId = ctx?.agentId;
if (!initiatorAgentId) {
return errorResult("Cannot resolve initiator agentId from session");
}
if (!moderatorBotToken) {
return errorResult("moderatorBotToken not configured");
}
if (!onDiscussionCreate) {
return errorResult("Discussion service not available");
}
const botId = getBotUserIdFromToken(moderatorBotToken);
const initiatorDiscordId = identityRegistry.findByAgentId(initiatorAgentId)?.discordUserId;
const memberIds = [...new Set([
...(initiatorDiscordId ? [initiatorDiscordId] : []),
...p.participants,
...(botId ? [botId] : []),
])];
const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [
{ id: p.callbackGuildId, type: 0, deny: "1024" },
...memberIds.map((id) => ({ id, type: 1, allow: "1024" })),
];
let channelId: string;
try {
channelId = await createDiscordChannel({
token: moderatorBotToken,
guildId: p.callbackGuildId,
name: p.name,
permissionOverwrites: overwrites,
logger: api.logger,
});
} catch (err) {
return errorResult(`Failed to create channel: ${String(err)}`);
}
try {
channelStore.setLockedMode(channelId, "discussion", {
initiatorAgentId,
callbackGuildId: p.callbackGuildId,
callbackChannelId: p.callbackChannelId,
concluded: false,
});
} catch (err) {
return errorResult(`Failed to register channel: ${String(err)}`);
}
await onDiscussionCreate({
channelId,
guildId: p.callbackGuildId,
initiatorAgentId,
callbackGuildId: p.callbackGuildId,
callbackChannelId: p.callbackChannelId,
discussionGuide: p.discussionGuide,
participants: p.participants,
});
return textResult(`Discussion channel created: ${channelId}`);
},
}));
// ───────────────────────────────────────────────
// discussion-complete
// ───────────────────────────────────────────────
api.registerTool((ctx) => ({
name: "discussion-complete",
label: "Discussion Complete",
description: "Mark a discussion as complete, archive the channel, and post the summary path to the callback channel.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
discussionChannelId: { type: "string", description: "The discussion channel ID" },
summary: { type: "string", description: "File path to the summary (must be under {workspace}/discussion-summary/)" },
},
required: ["discussionChannelId", "summary"],
},
execute: async (_toolCallId: string, params: unknown) => {
const p = params as { discussionChannelId: string; summary: string };
const callerAgentId = ctx?.agentId;
if (!callerAgentId) {
return errorResult("Cannot resolve agentId from session");
}
const rec = channelStore.getRecord(p.discussionChannelId);
if (rec.mode !== "discussion") {
return errorResult(`Channel ${p.discussionChannelId} is not a discussion channel`);
}
if (!rec.discussion) {
return errorResult("Discussion metadata not found");
}
if (rec.discussion.initiatorAgentId !== callerAgentId) {
return errorResult(`Only the initiator (${rec.discussion.initiatorAgentId}) may call discussion-complete`);
}
if (!p.summary.includes("discussion-summary")) {
return errorResult("Summary path must be under {workspace}/discussion-summary/");
}
channelStore.concludeDiscussion(p.discussionChannelId);
if (moderatorBotToken) {
const { sendModeratorMessage } = await import("../core/moderator-discord.js");
await sendModeratorMessage(
moderatorBotToken, rec.discussion.callbackChannelId,
`Discussion complete. Summary: ${p.summary}`,
api.logger,
).catch(() => undefined);
}
return textResult(`Discussion ${p.discussionChannelId} concluded. Summary posted to ${rec.discussion.callbackChannelId}.`);
},
}));
}

View File

@@ -1,233 +1,284 @@
/**
* Turn-based speaking manager for group channels.
* Turn Manager (v2)
*
* Rules:
* - Humans (humanList) are never in the turn order
* - Turn order is auto-populated from channel/server members minus humans
* - currentSpeaker can be null (dormant state)
* - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null)
* - Dormant → any new message reactivates:
* - If sender is NOT in turn order → current = first in list
* - If sender IS in turn order → current = next after sender
* Per-channel state machine governing who speaks when.
* Called from before_model_resolve (check turn) and agent_end (advance turn).
*/
export type ChannelTurnState = {
/** Ordered accountIds for this channel (auto-populated, shuffled) */
turnOrder: string[];
/** Current speaker accountId, or null if dormant */
currentSpeaker: string | null;
/** Set of accountIds that have NO_REPLY'd this cycle */
noRepliedThisCycle: Set<string>;
/** Timestamp of last state change */
lastChangedAt: number;
export type SpeakerEntry = {
agentId: string;
discordUserId: string;
};
const channelTurns = new Map<string, ChannelTurnState>();
/** Turn timeout: if the current speaker hasn't responded, auto-advance */
const TURN_TIMEOUT_MS = 60_000;
// --- helpers ---
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// --- public API ---
type ChannelTurnState = {
speakerList: SpeakerEntry[];
currentIndex: number;
/** Tracks which agents sent empty turns in the current cycle. */
emptyThisCycle: Set<string>;
/** Tracks which agents completed a turn at all this cycle. */
completedThisCycle: Set<string>;
dormant: boolean;
/** Discord message ID recorded at before_model_resolve, used as poll anchor. */
anchorMessageId: Map<string, string>; // agentId → messageId
};
/**
* Initialize or update the turn order for a channel.
* Called with the list of bot accountIds (already filtered, humans excluded).
* All mutable state is stored on globalThis so it persists across VM-context
* hot-reloads within the same gateway process. OpenClaw re-imports this module
* in a fresh isolated VM context on each reload, but all contexts share the real
* globalThis object because they run in the same Node.js process.
*/
export function initTurnOrder(channelId: string, botAccountIds: string[]): void {
const existing = channelTurns.get(channelId);
if (existing) {
// Check if membership changed
const oldSet = new Set(existing.turnOrder);
const newSet = new Set(botAccountIds);
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
if (same) return; // no change
}
const _G = globalThis as Record<string, unknown>;
channelTurns.set(channelId, {
turnOrder: shuffleArray(botAccountIds),
currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
});
function channelStates(): Map<string, ChannelTurnState> {
if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map<string, ChannelTurnState>();
return _G._tmChannelStates as Map<string, ChannelTurnState>;
}
function pendingTurns(): Set<string> {
if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set<string>();
return _G._tmPendingTurns as Set<string>;
}
function blockedPendingCounts(): Map<string, number> {
if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map<string, number>();
return _G._tmBlockedPendingCounts as Map<string, number>;
}
/**
* Check if the given accountId is allowed to speak.
* Shared initialization lock: prevents multiple concurrent VM contexts from
* simultaneously initializing the same channel's speaker list.
* Used by both before_model_resolve and message_received hooks.
*/
export function checkTurn(channelId: string, accountId: string): {
allowed: boolean;
currentSpeaker: string | null;
reason: string;
} {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) {
return { allowed: true, currentSpeaker: null, reason: "no_turn_state" };
}
// Not in turn order (human or unknown) → always allowed
if (!state.turnOrder.includes(accountId)) {
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
}
// Dormant → not allowed (will be activated by onNewMessage)
if (state.currentSpeaker === null) {
return { allowed: false, currentSpeaker: null, reason: "dormant" };
}
// Check timeout → auto-advance
if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) {
advanceTurn(channelId);
// Re-check after advance
const updated = channelTurns.get(channelId)!;
if (updated.currentSpeaker === accountId) {
return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" };
}
return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" };
}
if (accountId === state.currentSpeaker) {
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" };
}
return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" };
export function getInitializingChannels(): Set<string> {
if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
return _G._tmInitializingChannels as Set<string>;
}
/**
* Called when a new message arrives in the channel.
* Handles reactivation from dormant state and human-triggered resets.
* Maximum blocked-pending entries tracked per agent per channel.
* Caps the drain time: in a busy channel many messages can arrive during a non-speaker
* turn, each incrementing the counter. Without a cap the counter grows unboundedly.
* Also applied retroactively in markTurnStarted to recover from accumulated debt.
*/
const MAX_BLOCKED_PENDING = 3;
export function markTurnStarted(channelId: string, agentId: string): void {
pendingTurns().add(`${channelId}:${agentId}`);
// Cap existing blocked-pending at MAX to recover from accumulated debt
// (can occur when many messages arrive during a long non-speaker period).
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
const current = bpc.get(key) ?? 0;
if (current > MAX_BLOCKED_PENDING) bpc.set(key, MAX_BLOCKED_PENDING);
}
export function isTurnPending(channelId: string, agentId: string): boolean {
return pendingTurns().has(`${channelId}:${agentId}`);
}
export function clearTurnPending(channelId: string, agentId: string): void {
pendingTurns().delete(`${channelId}:${agentId}`);
}
/**
* Counts NO_REPLY completions currently in-flight for an agent that was
* blocked (non-speaker or init-suppressed). These completions take ~10s to
* arrive (history-building overhead) and may arrive after markTurnStarted,
* causing false empty-turn detection. We count them and skip one per agent_end
* until the count reaches zero, at which point the next agent_end is real.
*/
export function incrementBlockedPending(channelId: string, agentId: string): void {
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
const current = bpc.get(key) ?? 0;
if (current < MAX_BLOCKED_PENDING) bpc.set(key, current + 1);
}
/** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */
export function consumeBlockedPending(channelId: string, agentId: string): boolean {
const bpc = blockedPendingCounts();
const key = `${channelId}:${agentId}`;
const count = bpc.get(key) ?? 0;
if (count <= 0) return false;
bpc.set(key, count - 1);
return true;
}
export function resetBlockedPending(channelId: string, agentId: string): void {
blockedPendingCounts().delete(`${channelId}:${agentId}`);
}
function getState(channelId: string): ChannelTurnState | undefined {
return channelStates().get(channelId);
}
function ensureState(channelId: string): ChannelTurnState {
const cs = channelStates();
let s = cs.get(channelId);
if (!s) {
s = {
speakerList: [],
currentIndex: 0,
emptyThisCycle: new Set(),
completedThisCycle: new Set(),
dormant: false,
anchorMessageId: new Map(),
};
cs.set(channelId, s);
}
return s;
}
/** Replace the speaker list (called at cycle boundaries and on init). */
export function setSpeakerList(channelId: string, speakers: SpeakerEntry[]): void {
const s = ensureState(channelId);
s.speakerList = speakers;
s.currentIndex = 0;
}
/** Get the currently active speaker, or null if dormant / list empty. */
export function getCurrentSpeaker(channelId: string): SpeakerEntry | null {
const s = getState(channelId);
if (!s || s.dormant || s.speakerList.length === 0) return null;
return s.speakerList[s.currentIndex] ?? null;
}
/** Check if a given agentId is the current speaker. */
export function isCurrentSpeaker(channelId: string, agentId: string): boolean {
const speaker = getCurrentSpeaker(channelId);
return speaker?.agentId === agentId;
}
/** Record the Discord anchor message ID for an agent's upcoming turn. */
export function setAnchor(channelId: string, agentId: string, messageId: string): void {
const s = ensureState(channelId);
s.anchorMessageId.set(agentId, messageId);
}
export function getAnchor(channelId: string, agentId: string): string | undefined {
return getState(channelId)?.anchorMessageId.get(agentId);
}
/**
* Advance the speaker after a turn completes.
* Returns the new current speaker (or null if dormant).
*
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
* @param isHuman - whether the sender is in the humanList
* @param isEmpty - whether the completed turn was an empty turn
* @param rebuildFn - async function that fetches current Discord members and
* returns a new SpeakerEntry[]. Called at cycle boundaries.
* @param previousLastAgentId - for shuffle mode: the last speaker of the
* previous cycle (cannot become the new first speaker).
*/
export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return;
export async function advanceSpeaker(
channelId: string,
agentId: string,
isEmpty: boolean,
rebuildFn: () => Promise<SpeakerEntry[]>,
previousLastAgentId?: string,
): Promise<{ next: SpeakerEntry | null; enteredDormant: boolean }> {
const s = ensureState(channelId);
if (isHuman) {
// Human message: activate, start from first in order
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return;
// Record this turn
s.completedThisCycle.add(agentId);
if (isEmpty) s.emptyThisCycle.add(agentId);
const wasLastInCycle = s.currentIndex >= s.speakerList.length - 1;
if (!wasLastInCycle) {
// Middle of cycle — just advance pointer
s.currentIndex++;
s.dormant = false;
return { next: s.speakerList[s.currentIndex] ?? null, enteredDormant: false };
}
if (state.currentSpeaker !== null) {
// Already active, no change needed from incoming message
return;
// === Cycle boundary ===
const newSpeakers = await rebuildFn();
const previousAgentIds = new Set(s.speakerList.map((sp) => sp.agentId));
const hasNewAgents = newSpeakers.some((sp) => !previousAgentIds.has(sp.agentId));
const allEmpty =
s.completedThisCycle.size > 0 &&
[...s.completedThisCycle].every((id) => s.emptyThisCycle.has(id));
// Reset cycle tracking
s.emptyThisCycle = new Set();
s.completedThisCycle = new Set();
if (allEmpty && !hasNewAgents) {
// Enter dormant
s.speakerList = newSpeakers;
s.currentIndex = 0;
s.dormant = true;
return { next: null, enteredDormant: true };
}
// Dormant state + non-human message → reactivate
if (senderAccountId && state.turnOrder.includes(senderAccountId)) {
// Sender is in turn order → next after sender
const idx = state.turnOrder.indexOf(senderAccountId);
const nextIdx = (idx + 1) % state.turnOrder.length;
state.currentSpeaker = state.turnOrder[nextIdx];
} else {
// Sender not in turn order → start from first
state.currentSpeaker = state.turnOrder[0];
}
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
// Continue with updated list (apply shuffle if caller provides previousLastAgentId)
s.speakerList = previousLastAgentId != null
? shuffleList(newSpeakers, previousLastAgentId)
: newSpeakers;
s.currentIndex = 0;
s.dormant = false;
return { next: s.speakerList[0] ?? null, enteredDormant: false };
}
/**
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
* @returns the new currentSpeaker (or null if dormant)
* Wake the channel from dormant.
* Returns the new first speaker.
*/
export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null {
const state = channelTurns.get(channelId);
if (!state) return null;
if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore
export function wakeFromDormant(channelId: string): SpeakerEntry | null {
const s = getState(channelId);
if (!s) return null;
s.dormant = false;
s.currentIndex = 0;
s.emptyThisCycle = new Set();
s.completedThisCycle = new Set();
return s.speakerList[0] ?? null;
}
if (wasNoReply) {
state.noRepliedThisCycle.add(accountId);
export function isDormant(channelId: string): boolean {
return getState(channelId)?.dormant ?? false;
}
// Check if ALL agents have NO_REPLY'd this cycle
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
if (allNoReplied) {
// Go dormant
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return null;
}
} else {
// Successful speech resets the cycle counter
state.noRepliedThisCycle = new Set();
}
return advanceTurn(channelId);
export function hasSpeakers(channelId: string): boolean {
const s = getState(channelId);
return (s?.speakerList.length ?? 0) > 0;
}
/**
* Advance to next speaker in order.
* Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
*/
export function advanceTurn(channelId: string): string | null {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return null;
if (state.currentSpeaker === null) return null;
const idx = state.turnOrder.indexOf(state.currentSpeaker);
const nextIdx = (idx + 1) % state.turnOrder.length;
// Skip agents that already NO_REPLY'd this cycle
let attempts = 0;
let candidateIdx = nextIdx;
while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) {
candidateIdx = (candidateIdx + 1) % state.turnOrder.length;
attempts++;
export function shuffleList(list: SpeakerEntry[], previousLastAgentId?: string): SpeakerEntry[] {
if (list.length <= 1) return list;
const arr = [...list];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
if (attempts >= state.turnOrder.length) {
// All have NO_REPLY'd
state.currentSpeaker = null;
state.lastChangedAt = Date.now();
return null;
if (previousLastAgentId && arr[0].agentId === previousLastAgentId && arr.length > 1) {
const swapIdx = 1 + Math.floor(Math.random() * (arr.length - 1));
[arr[0], arr[swapIdx]] = [arr[swapIdx], arr[0]];
}
state.currentSpeaker = state.turnOrder[candidateIdx];
state.lastChangedAt = Date.now();
return state.currentSpeaker;
return arr;
}
/**
* Force reset: go dormant.
*/
export function resetTurn(channelId: string): void {
const state = channelTurns.get(channelId);
if (state) {
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
}
}
/**
* Get debug info.
*/
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
const state = channelTurns.get(channelId);
if (!state) return { channelId, hasTurnState: false };
export function getDebugInfo(channelId: string) {
const s = getState(channelId);
if (!s) return { exists: false };
return {
channelId,
hasTurnState: true,
turnOrder: state.turnOrder,
currentSpeaker: state.currentSpeaker,
noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null,
exists: true,
speakerList: s.speakerList.map((sp) => sp.agentId),
currentIndex: s.currentIndex,
currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null,
dormant: s.dormant,
emptyThisCycle: [...s.emptyThisCycle],
completedThisCycle: [...s.completedThisCycle],
};
}
/** Remove a channel's turn state entirely (e.g. when archived). */
export function clearChannel(channelId: string): void {
channelStates().delete(channelId);
}

294
plugin/web/control-page.ts Normal file
View File

@@ -0,0 +1,294 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore, ChannelMode } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { fetchAdminGuilds, fetchGuildChannels } from "../core/moderator-discord.js";
import { scanPaddedCell } from "../core/padded-cell.js";
import path from "node:path";
import os from "node:os";
const SWITCHABLE_MODES: ChannelMode[] = ["none", "chat", "report"];
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
function html(strings: TemplateStringsArray, ...values: unknown[]): string {
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
}
function escapeHtml(s: unknown): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function modeBadge(mode: ChannelMode): string {
const colors: Record<ChannelMode, string> = {
none: "#888", chat: "#5865f2", report: "#57f287",
work: "#fee75c", discussion: "#eb459e",
};
return `<span style="background:${colors[mode]};color:#fff;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">${escapeHtml(mode)}</span>`;
}
function buildPage(content: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dirigent</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:24px}
h1{font-size:1.6rem;margin-bottom:4px;color:#fff}
.subtitle{color:#888;font-size:0.85rem;margin-bottom:24px}
h2{font-size:1.1rem;margin:24px 0 12px;color:#ccc;border-bottom:1px solid #333;padding-bottom:8px}
table{width:100%;border-collapse:collapse;margin-bottom:16px;font-size:0.9rem}
th{text-align:left;padding:8px 12px;background:#252540;color:#aaa;font-weight:600;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em}
td{padding:8px 12px;border-top:1px solid #2a2a4a}
tr:hover td{background:#1e1e3a}
input,select{background:#252540;border:1px solid #444;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:0.9rem}
input:focus,select:focus{outline:none;border-color:#5865f2}
button{background:#5865f2;color:#fff;border:none;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}
button:hover{background:#4752c4}
button.danger{background:#ed4245}
button.danger:hover{background:#c03537}
button.secondary{background:#36393f}
button.secondary:hover{background:#2f3136}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.guild-section{background:#16213e;border:1px solid #2a2a4a;border-radius:8px;margin-bottom:16px;overflow:hidden}
.guild-header{padding:12px 16px;background:#1f2d50;display:flex;align-items:center;gap:10px;font-weight:600}
.guild-name{font-size:1rem;color:#fff}
.guild-id{font-size:0.75rem;color:#888;font-family:monospace}
.msg{padding:8px 12px;border-radius:4px;margin:8px 0;font-size:0.85rem}
.msg.ok{background:#1a4a2a;border:1px solid #2d7a3a;color:#57f287}
.msg.err{background:#4a1a1a;border:1px solid #7a2d2d;color:#ed4245}
.spinner{display:none}
</style>
</head>
<body>
<h1>Dirigent</h1>
<p class="subtitle">OpenClaw multi-agent turn management</p>
${content}
<script>
async function apiCall(endpoint, method, body) {
const resp = await fetch(endpoint, {
method: method || 'GET',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
return resp.json();
}
function showMsg(el, text, isErr) {
el.className = 'msg ' + (isErr ? 'err' : 'ok');
el.textContent = text;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
</script>
</body>
</html>`;
}
export function registerControlPage(deps: {
api: OpenClawPluginApi;
channelStore: ChannelStore;
identityRegistry: IdentityRegistry;
moderatorBotToken: string | undefined;
openclawDir: string;
hasPaddedCell: () => boolean;
}): void {
const { api, channelStore, identityRegistry, moderatorBotToken, openclawDir, hasPaddedCell } = deps;
// ── Main page ──────────────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent",
auth: "plugin",
match: "exact",
handler: async (_req, res) => {
const entries = identityRegistry.list();
const paddedCellBtn = hasPaddedCell()
? `<button class="secondary" onclick="rescanPaddedCell()">Re-scan padded-cell</button>`
: "";
// Build identity table rows
const identityRows = entries.map((e) => html`
<tr data-agent-id="${escapeHtml(e.agentId)}">
<td><code>${escapeHtml(e.discordUserId)}</code></td>
<td>${escapeHtml(e.agentId)}</td>
<td>${escapeHtml(e.agentName)}</td>
<td><button class="danger" onclick="removeIdentity('${escapeHtml(e.agentId)}')">Remove</button></td>
</tr>`).join("");
// Build guild sections
let guildHtml = "<p style='color:#888'>Loading guilds…</p>";
if (moderatorBotToken) {
try {
const guilds = await fetchAdminGuilds(moderatorBotToken);
if (guilds.length === 0) {
guildHtml = "<p style='color:#888'>No guilds with admin permissions found.</p>";
} else {
guildHtml = "";
for (const guild of guilds) {
const channels = await fetchGuildChannels(moderatorBotToken, guild.id);
const channelRows = channels.map((ch) => {
const mode = channelStore.getMode(ch.id);
const locked = LOCKED_MODES.has(mode);
const dropdown = locked
? modeBadge(mode)
: `<select onchange="setMode('${escapeHtml(ch.id)}', this.value)">
${SWITCHABLE_MODES.map((m) => `<option value="${m}"${m === mode ? " selected" : ""}>${m}</option>`).join("")}
</select>`;
return html`<tr>
<td><code style="font-size:0.8rem">${escapeHtml(ch.id)}</code></td>
<td>#${escapeHtml(ch.name)}</td>
<td>${dropdown}</td>
</tr>`;
}).join("");
guildHtml += html`
<div class="guild-section">
<div class="guild-header">
<span class="guild-name">${escapeHtml(guild.name)}</span>
<span class="guild-id">${escapeHtml(guild.id)}</span>
</div>
<table>
<thead><tr><th>Channel ID</th><th>Name</th><th>Mode</th></tr></thead>
<tbody>${channelRows}</tbody>
</table>
</div>`;
}
}
} catch (err) {
guildHtml = `<p style="color:#ed4245">Failed to load guilds: ${escapeHtml(String(err))}</p>`;
}
} else {
guildHtml = "<p style='color:#888'>moderatorBotToken not configured — cannot list guilds.</p>";
}
const content = html`
<h2>Identity Registry</h2>
<div id="identity-msg" class="msg" style="display:none"></div>
<table>
<thead><tr><th>Discord User ID</th><th>Agent ID</th><th>Agent Name</th><th></th></tr></thead>
<tbody id="identity-tbody">${identityRows}</tbody>
</table>
<div class="row">
<input id="new-discord-id" placeholder="Discord user ID" style="width:200px">
<input id="new-agent-id" placeholder="Agent ID" style="width:160px">
<input id="new-agent-name" placeholder="Agent name (optional)" style="width:180px">
<button onclick="addIdentity()">Add</button>
${paddedCellBtn}
</div>
<h2>Guild &amp; Channel Configuration</h2>
<div id="channel-msg" class="msg" style="display:none"></div>
${guildHtml}
<script>
async function addIdentity() {
const discordUserId = document.getElementById('new-discord-id').value.trim();
const agentId = document.getElementById('new-agent-id').value.trim();
const agentName = document.getElementById('new-agent-name').value.trim();
if (!discordUserId || !agentId) return alert('Discord user ID and Agent ID are required');
const r = await apiCall('/dirigent/api/identity', 'POST', { discordUserId, agentId, agentName: agentName || agentId });
showMsg(document.getElementById('identity-msg'), r.ok ? 'Added.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function removeIdentity(agentId) {
if (!confirm('Remove identity for ' + agentId + '?')) return;
const r = await apiCall('/dirigent/api/identity/' + encodeURIComponent(agentId), 'DELETE');
showMsg(document.getElementById('identity-msg'), r.ok ? 'Removed.' : r.error, !r.ok);
if (r.ok) location.reload();
}
async function setMode(channelId, mode) {
const r = await apiCall('/dirigent/api/channel-mode', 'POST', { channelId, mode });
showMsg(document.getElementById('channel-msg'), r.ok ? 'Mode updated.' : r.error, !r.ok);
}
async function rescanPaddedCell() {
const r = await apiCall('/dirigent/api/rescan-padded-cell', 'POST');
showMsg(document.getElementById('identity-msg'), r.ok ? ('Scanned: ' + r.count + ' entries.') : r.error, !r.ok);
if (r.ok) location.reload();
}
</script>`;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildPage(content));
},
});
// ── API: add identity ──────────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/identity",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
try {
const { discordUserId, agentId, agentName } = JSON.parse(body);
if (!discordUserId || !agentId) throw new Error("discordUserId and agentId required");
identityRegistry.upsert({ discordUserId, agentId, agentName: agentName ?? agentId });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err) }));
}
});
},
});
// ── API: remove identity ───────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/identity/",
auth: "plugin",
match: "prefix",
handler: (req, res) => {
if (req.method !== "DELETE") { res.writeHead(405); res.end(); return; }
const agentId = decodeURIComponent((req.url ?? "").replace("/dirigent/api/identity/", ""));
const removed = identityRegistry.remove(agentId);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: removed, error: removed ? undefined : "Not found" }));
},
});
// ── API: set channel mode ──────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/channel-mode",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
try {
const { channelId, mode } = JSON.parse(body) as { channelId: string; mode: ChannelMode };
if (!channelId || !mode) throw new Error("channelId and mode required");
if (LOCKED_MODES.has(mode)) throw new Error(`Mode "${mode}" is locked to creation tools`);
channelStore.setMode(channelId, mode);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err) }));
}
});
},
});
// ── API: rescan padded-cell ────────────────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/rescan-padded-cell",
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
const count = scanPaddedCell(identityRegistry, openclawDir, api.logger);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: count >= 0, count, error: count < 0 ? "padded-cell not detected" : undefined }));
},
});
}

112
plugin/web/dirigent-api.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ChannelStore } from "../core/channel-store.js";
type Deps = {
api: OpenClawPluginApi;
channelStore: ChannelStore;
moderatorBotUserId: string | undefined;
scheduleIdentifier: string;
moderatorServiceUrl: string | undefined;
moderatorServiceToken: string | undefined;
debugMode: boolean;
onNewMessage: (event: {
channelId: string;
messageId: string;
senderId: string;
guildId?: string;
}) => Promise<void>;
};
function sendJson(res: import("node:http").ServerResponse, status: number, payload: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function readBody(req: import("node:http").IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
if (body.length > 1_000_000) {
req.destroy();
reject(new Error("body too large"));
}
});
req.on("end", () => {
try {
resolve(body ? (JSON.parse(body) as Record<string, unknown>) : {});
} catch {
reject(new Error("invalid_json"));
}
});
req.on("error", reject);
});
}
/**
* Register Dirigent plugin HTTP routes that the moderator service calls back into.
*
* Routes:
* POST /dirigent/api/moderator/message — inbound message notification from moderator service
* GET /dirigent/api/moderator/status — health/status check
*/
export function registerDirigentApi(deps: Deps): void {
const { api, moderatorServiceUrl, onNewMessage } = deps;
// ── POST /dirigent/api/moderator/message ─────────────────────────────────────
// Called by the moderator service on every Discord MESSAGE_CREATE event.
api.registerHttpRoute({
path: "/dirigent/api/moderator/message",
auth: "plugin",
match: "exact",
handler: async (req, res) => {
if (req.method !== "POST") {
res.writeHead(405);
res.end();
return;
}
let body: Record<string, unknown>;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const channelId = typeof body.channelId === "string" ? body.channelId : undefined;
const messageId = typeof body.messageId === "string" ? body.messageId : undefined;
const senderId = typeof body.senderId === "string" ? body.senderId : undefined;
const guildId = typeof body.guildId === "string" ? body.guildId : undefined;
if (!channelId || !senderId) {
return sendJson(res, 400, { ok: false, error: "channelId and senderId required" });
}
try {
await onNewMessage({
channelId,
messageId: messageId ?? "",
senderId,
guildId,
});
return sendJson(res, 200, { ok: true });
} catch (err) {
api.logger.warn(`dirigent: moderator/message handler error: ${String(err)}`);
return sendJson(res, 500, { ok: false, error: String(err) });
}
},
});
// ── GET /dirigent/api/moderator/status ───────────────────────────────────────
api.registerHttpRoute({
path: "/dirigent/api/moderator/status",
auth: "plugin",
match: "exact",
handler: (_req, res) => {
return sendJson(res, 200, {
ok: true,
moderatorServiceUrl: moderatorServiceUrl ?? null,
});
},
});
}

View File

@@ -1,20 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(process.cwd(), '..');
const pluginDir = path.join(root, 'plugin');
const required = ['index.ts', 'rules.ts', 'openclaw.plugin.json', 'README.md', 'package.json'];
const root = path.resolve(import.meta.dirname, '..');
const checks = [
// Core plugin files
path.join(root, 'plugin', 'index.ts'),
path.join(root, 'plugin', 'turn-manager.ts'),
path.join(root, 'plugin', 'openclaw.plugin.json'),
path.join(root, 'plugin', 'package.json'),
// Sidecar
path.join(root, 'services', 'main.mjs'),
path.join(root, 'services', 'no-reply-api', 'server.mjs'),
path.join(root, 'services', 'moderator', 'index.mjs'),
];
let ok = true;
for (const f of required) {
const p = path.join(pluginDir, f);
for (const p of checks) {
if (!fs.existsSync(p)) {
ok = false;
console.error(`missing: ${p}`);
}
}
const manifestPath = path.join(pluginDir, 'openclaw.plugin.json');
const manifestPath = path.join(root, 'plugin', 'openclaw.plugin.json');
if (fs.existsSync(manifestPath)) {
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
for (const k of ['id', 'entry', 'configSchema']) {

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
docker compose down

View File

@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
echo "[whispergate] building/starting no-reply API container"
docker compose up -d --build whispergate-no-reply-api
echo "[whispergate] health check"
curl -sS http://127.0.0.1:8787/health
echo "[whispergate] done"

View File

@@ -1,224 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
const modeArg = process.argv[2];
if (modeArg !== "--install" && modeArg !== "--uninstall") {
console.error("Usage: install-whispergate-openclaw.mjs --install | --uninstall");
process.exit(2);
}
const mode = modeArg === "--install" ? "install" : "uninstall";
const env = process.env;
const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "whispergate");
const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway";
const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";
const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token";
const LIST_MODE = env.LIST_MODE || "human-list";
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]";
const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/whispergate-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir());
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
const STATE_DIR = (env.STATE_DIR || "~/.openclaw/whispergate-install-records").replace(/^~(?=$|\/)/, os.homedir());
const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/whispergate-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-whispergate-${mode}-${ts}`;
const RECORD_PATH = path.join(STATE_DIR, `whispergate-${ts}.json`);
const PATH_PLUGINS_LOAD = "plugins.load.paths";
const PATH_PLUGIN_ENTRY = "plugins.entries.whispergate";
const PATH_PROVIDERS = "models.providers";
function runOpenclaw(args, { allowFail = false } = {}) {
try {
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
} catch (e) {
if (allowFail) return null;
throw e;
}
}
function getJson(pathKey) {
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
if (out == null || out === "") return { exists: false };
return { exists: true, value: JSON.parse(out) };
}
function setJson(pathKey, value) {
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
}
function unsetPath(pathKey) {
runOpenclaw(["config", "unset", pathKey], { allowFail: true });
}
function writeRecord(modeName, before, after) {
fs.mkdirSync(STATE_DIR, { recursive: true });
const rec = {
mode: modeName,
timestamp: ts,
openclawConfigPath: OPENCLAW_CONFIG_PATH,
backupPath: BACKUP_PATH,
paths: before,
applied: after,
};
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
}
function readRecord(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function findLatestInstallRecord() {
if (!fs.existsSync(STATE_DIR)) return "";
const files = fs
.readdirSync(STATE_DIR)
.filter((f) => /^whispergate-\d+\.json$/.test(f))
.sort()
.reverse();
for (const f of files) {
const p = path.join(STATE_DIR, f);
try {
const rec = readRecord(p);
if (rec?.mode === "install") return p;
} catch {
// ignore broken records
}
}
return "";
}
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`);
process.exit(1);
}
if (mode === "install") {
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[whispergate] backup: ${BACKUP_PATH}`);
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
console.log(`[whispergate] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
}
const before = {
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
try {
const pluginsNow = getJson("plugins").value || {};
const plugins = typeof pluginsNow === "object" ? pluginsNow : {};
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {};
const paths = Array.isArray(plugins.load.paths) ? plugins.load.paths : [];
if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH);
plugins.load.paths = paths;
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {};
plugins.entries.whispergate = {
enabled: true,
config: {
enabled: true,
discordOnly: true,
listMode: LIST_MODE,
humanList: JSON.parse(HUMAN_LIST_JSON),
agentList: JSON.parse(AGENT_LIST_JSON),
channelPoliciesFile: CHANNEL_POLICIES_FILE,
endSymbols: JSON.parse(END_SYMBOLS_JSON),
noReplyProvider: NO_REPLY_PROVIDER_ID,
noReplyModel: NO_REPLY_MODEL_ID,
},
};
setJson("plugins", plugins);
const providersNow = getJson(PATH_PROVIDERS).value || {};
const providers = typeof providersNow === "object" ? providersNow : {};
providers[NO_REPLY_PROVIDER_ID] = {
baseUrl: NO_REPLY_BASE_URL,
apiKey: NO_REPLY_API_KEY,
api: "openai-completions",
models: [
{
id: NO_REPLY_MODEL_ID,
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
};
setJson(PATH_PROVIDERS, providers);
const after = {
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
writeRecord("install", before, after);
console.log("[whispergate] install ok (config written)");
console.log(`[whispergate] record: ${RECORD_PATH}`);
console.log("[whispergate] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[whispergate] install failed; rollback complete: ${String(e)}`);
process.exit(1);
}
} else {
const recFile = env.RECORD_FILE || findLatestInstallRecord();
if (!recFile || !fs.existsSync(recFile)) {
console.error("[whispergate] no install record found. set RECORD_FILE=<path> to an install record.");
process.exit(1);
}
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[whispergate] backup before uninstall: ${BACKUP_PATH}`);
const rec = readRecord(recFile);
const before = rec.applied || {};
const target = rec.paths || {};
try {
const pluginsNow = getJson("plugins").value || {};
const plugins = typeof pluginsNow === "object" ? pluginsNow : {};
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {};
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {};
if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value;
else delete plugins.load.paths;
if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.whispergate = target[PATH_PLUGIN_ENTRY].value;
else delete plugins.entries.whispergate;
setJson("plugins", plugins);
if (target[PATH_PROVIDERS]?.exists) setJson(PATH_PROVIDERS, target[PATH_PROVIDERS].value);
else unsetPath(PATH_PROVIDERS);
const after = {
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
writeRecord("uninstall", before, after);
console.log("[whispergate] uninstall ok");
console.log(`[whispergate] record: ${RECORD_PATH}`);
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[whispergate] uninstall failed; rollback complete: ${String(e)}`);
process.exit(1);
}
}

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec node "$SCRIPT_DIR/install-whispergate-openclaw.mjs" "$@"

254
scripts/install.mjs Executable file
View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
// === Skill merge utilities ===
function extractGuildTable(skillMdContent) {
const tableMatch = skillMdContent.match(/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/);
if (!tableMatch) return null;
const lines = tableMatch[0].split("\n");
const dataRows = [];
for (const line of lines) {
if (line.includes("guild-id") && line.includes("description")) continue;
if (/^\|[-\s|]+\|$/.test(line)) continue;
const match = line.match(/^\| \s*(\d+) \s*\| \s*(.+?) \s*\|$/);
if (match) dataRows.push({ guildId: match[1].trim(), description: match[2].trim() });
}
return dataRows;
}
function buildGuildTable(rows) {
if (rows.length === 0) return "| guild-id | description |\n|----------|-------------|";
const header = "| guild-id | description |\n|----------|-------------|";
const dataLines = rows.map(r => `| ${r.guildId} | ${r.description} |`).join("\n");
return `${header}\n${dataLines}`;
}
function mergeGuildTables(existingRows, newRows) {
const seen = new Set();
const merged = [];
for (const row of existingRows) {
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
}
for (const row of newRows) {
if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); }
}
return merged;
}
function installSkillWithMerge(skillName, pluginSkillDir, openClawSkillsDir) {
const targetSkillDir = path.join(openClawSkillsDir, skillName);
const sourceSkillMd = path.join(pluginSkillDir, "SKILL.md");
if (fs.existsSync(targetSkillDir)) {
const existingSkillMd = path.join(targetSkillDir, "SKILL.md");
if (fs.existsSync(existingSkillMd) && fs.existsSync(sourceSkillMd)) {
const existingContent = fs.readFileSync(existingSkillMd, "utf8");
const newContent = fs.readFileSync(sourceSkillMd, "utf8");
const existingRows = extractGuildTable(existingContent) || [];
const newRows = extractGuildTable(newContent) || [];
if (existingRows.length > 0 || newRows.length > 0) {
const mergedRows = mergeGuildTables(existingRows, newRows);
const mergedTable = buildGuildTable(mergedRows);
const finalContent = newContent.replace(
/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/,
mergedTable
);
fs.rmSync(targetSkillDir, { recursive: true, force: true });
fs.mkdirSync(targetSkillDir, { recursive: true });
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true });
fs.writeFileSync(path.join(targetSkillDir, "SKILL.md"), finalContent, "utf8");
return { merged: true, rowCount: mergedRows.length };
}
}
}
fs.mkdirSync(openClawSkillsDir, { recursive: true });
fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true, force: true });
return { merged: false };
}
// === End skill merge utilities ===
const VALID_MODES = new Set(["--install", "--uninstall", "--update"]);
let modeArg = null;
let argOpenClawDir = null;
let argNoReplyPort = 8787;
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (VALID_MODES.has(arg)) modeArg = arg;
else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) argOpenClawDir = process.argv[++i];
else if (arg.startsWith("--openclaw-profile-path=")) argOpenClawDir = arg.split("=").slice(1).join("=");
else if (arg === "--no-reply-port" && i + 1 < process.argv.length) argNoReplyPort = Number(process.argv[++i]);
else if (arg.startsWith("--no-reply-port=")) argNoReplyPort = Number(arg.split("=").slice(1).join("="));
}
if (!modeArg) {
console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path <path>] [--no-reply-port <port>]");
process.exit(2);
}
const mode = modeArg.slice(2);
const C = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m" };
function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; }
function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); }
function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); }
function ok(msg) { console.log(color(`\t${msg}`, "green")); }
function resolveOpenClawDir() {
if (argOpenClawDir) {
const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir());
if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`);
return dir;
}
const fallback = path.join(os.homedir(), ".openclaw");
if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir");
return fallback;
}
const OPENCLAW_DIR = resolveOpenClawDir();
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const REPO_ROOT = path.resolve(__dirname, "..");
const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins");
const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent");
const SKILLS_DIR = path.join(OPENCLAW_DIR, "skills");
const PLUGIN_SKILLS_DIR = path.join(REPO_ROOT, "skills");
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent";
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort);
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/no-reply/v1`;
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
function runOpenclaw(args, allowFail = false) {
try { return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); }
catch (e) { if (allowFail) return null; throw e; }
}
function getJson(pathKey) {
const out = runOpenclaw(["config", "get", pathKey, "--json"], true);
if (!out || out === "undefined") return undefined;
try { return JSON.parse(out); } catch { return undefined; }
}
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); }
function syncDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true });
}
if (mode === "install") {
title("Install Dirigent with Skills");
step(1, 7, "build dist assets");
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
const sidecarSrc = path.resolve(REPO_ROOT, "services");
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
fs.rmSync(distPlugin, { recursive: true, force: true });
syncDirRecursive(pluginSrc, distPlugin);
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
ok("dist assets built");
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
ok("plugin files installed");
step(3, 7, "install skills with merge");
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => {
const full = path.join(PLUGIN_SKILLS_DIR, d);
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, "SKILL.md"));
});
for (const skillName of skills) {
const pluginSkillDir = path.join(PLUGIN_SKILLS_DIR, skillName);
const result = installSkillWithMerge(skillName, pluginSkillDir, SKILLS_DIR);
if (result.merged) {
ok(`skill ${skillName} installed (merged ${result.rowCount} guild entries)`);
} else {
ok(`skill ${skillName} installed`);
}
}
}
step(4, 7, "configure plugin entry");
// Plugin load path — safe to read/write (not sensitive)
const loadPaths = getJson("plugins.load.paths") || [];
if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) {
loadPaths.push(PLUGIN_INSTALL_DIR);
setJson("plugins.load.paths", loadPaths);
}
// For each config field: only write the default if the field has no value.
// Sensitive fields (e.g. moderatorBotToken) are never touched — user sets them manually.
// `getJson` returns undefined if the field is unset; __OPENCLAW_REDACTED__ counts as "set".
function setIfMissing(pathKey, defaultVal) {
const existing = getJson(pathKey);
if (existing === undefined || existing === null) setJson(pathKey, defaultVal);
}
setIfMissing("plugins.entries.dirigent.enabled", true);
const cp = "plugins.entries.dirigent.config";
setIfMissing(`${cp}.scheduleIdentifier`, "➡️");
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
setIfMissing(`${cp}.sideCarPort`, NO_REPLY_PORT);
// moderatorBotToken: intentionally not touched — set manually via:
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
ok("plugin configured");
step(5, 7, "configure no-reply provider");
setJson(`models.providers.${NO_REPLY_PROVIDER_ID}`, {
baseUrl: NO_REPLY_BASE_URL, apiKey: NO_REPLY_API_KEY, api: "openai-completions",
models: [{ id: NO_REPLY_MODEL_ID, name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192 }],
});
ok("provider configured");
step(6, 7, "add no-reply model to allowlist");
const agentsDefaultsModels = getJson("agents.defaults.models") || {};
agentsDefaultsModels[`${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`] = {};
setJson("agents.defaults.models", agentsDefaultsModels);
ok("model allowlisted");
step(7, 7, "enable plugin in allowlist");
const allow = getJson("plugins.allow") || [];
if (!allow.includes("dirigent")) { allow.push("dirigent"); setJson("plugins.allow", allow); }
ok(`installed (no-reply port: ${NO_REPLY_PORT})`);
console.log("↻ restart gateway: openclaw gateway restart");
process.exit(0);
}
if (mode === "uninstall") {
title("Uninstall");
step(1, 4, "remove allowlist + plugin entry");
const allow = getJson("plugins.allow") || [];
const idx = allow.indexOf("dirigent");
if (idx >= 0) { allow.splice(idx, 1); setJson("plugins.allow", allow); ok("removed from plugins.allow"); }
runOpenclaw(["config", "unset", "plugins.entries.dirigent"], true);
ok("removed plugin entry");
step(2, 4, "remove plugin load path");
const paths = getJson("plugins.load.paths") || [];
setJson("plugins.load.paths", paths.filter((p) => p !== PLUGIN_INSTALL_DIR));
ok("removed load path");
step(3, 4, "remove installed files");
if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true });
ok("removed plugin files");
step(4, 4, "remove skills");
if (fs.existsSync(PLUGIN_SKILLS_DIR)) {
const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => fs.statSync(path.join(PLUGIN_SKILLS_DIR, d)).isDirectory());
for (const skillName of skills) {
const targetSkillDir = path.join(SKILLS_DIR, skillName);
if (fs.existsSync(targetSkillDir)) {
fs.rmSync(targetSkillDir, { recursive: true, force: true });
ok(`removed skill ${skillName}`);
}
}
}
console.log("↻ restart gateway: openclaw gateway restart");
process.exit(0);
}
console.error("Unknown mode:", mode);
process.exit(2);

View File

@@ -1,15 +0,0 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const pluginDir = path.join(root, "plugin");
const outDir = path.join(root, "dist", "whispergate");
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
for (const f of ["index.ts", "rules.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
}
console.log(`packaged plugin to ${outDir}`);

View File

@@ -1,13 +1,13 @@
const pluginPath = process.argv[2] || "/opt/WhisperGate/plugin";
const pluginPath = process.argv[2] || "/opt/Dirigent/plugin";
const provider = process.argv[3] || "openai";
const model = process.argv[4] || "whispergate-no-reply-v1";
const model = process.argv[4] || "dirigent-no-reply-v1";
const bypass = (process.argv[5] || "").split(",").filter(Boolean);
const payload = {
plugins: {
load: { paths: [pluginPath] },
entries: {
whispergate: {
dirigent: {
enabled: true,
config: {
enabled: true,

View File

@@ -1,52 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8790}"
AUTH_TOKEN="${AUTH_TOKEN:-}"
CALLER_ID="${CALLER_ID:-}"
AUTH_HEADER=()
if [[ -n "$AUTH_TOKEN" ]]; then
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
fi
CALLER_HEADER=()
if [[ -n "$CALLER_ID" ]]; then
CALLER_HEADER=(-H "X-OpenClaw-Caller-Id: ${CALLER_ID}")
fi
echo "[1] health"
curl -sS "${BASE_URL}/health" | sed -n '1,20p'
if [[ -z "${GUILD_ID:-}" ]]; then
echo "skip action checks: set GUILD_ID (and optional CHANNEL_ID) to run dryRun actions"
exit 0
fi
echo "[2] dry-run private create"
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
"${CALLER_HEADER[@]}" \
-d "{\"action\":\"channel-private-create\",\"guildId\":\"${GUILD_ID}\",\"name\":\"wg-dryrun\",\"dryRun\":true}" \
| sed -n '1,80p'
if [[ -n "${CHANNEL_ID:-}" ]]; then
echo "[3] dry-run private update"
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
"${CALLER_HEADER[@]}" \
-d "{\"action\":\"channel-private-update\",\"guildId\":\"${GUILD_ID}\",\"channelId\":\"${CHANNEL_ID}\",\"mode\":\"merge\",\"dryRun\":true}" \
| sed -n '1,100p'
fi
echo "[4] member-list (limit=1)"
curl -sS -X POST "${BASE_URL}/v1/discord/action" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
"${CALLER_HEADER[@]}" \
-d "{\"action\":\"member-list\",\"guildId\":\"${GUILD_ID}\",\"limit\":1,\"fields\":[\"user.id\",\"user.username\"]}" \
| sed -n '1,120p'
echo "smoke-discord-control: done"

View File

@@ -1,32 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8787}"
AUTH_TOKEN="${AUTH_TOKEN:-}"
AUTH_HEADER=()
if [[ -n "$AUTH_TOKEN" ]]; then
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
fi
# Smoke-tests the no-reply API endpoint exposed by the sidecar service.
# The sidecar must already be running (it starts automatically with openclaw-gateway).
# Default base URL matches the sidecar's no-reply prefix.
BASE_URL="${BASE_URL:-http://127.0.0.1:8787/no-reply}"
echo "[1] health"
curl -sS "${BASE_URL}/health" | sed -n '1,3p'
curl -fsS "${BASE_URL}/health"
echo ""
echo "[2] models"
curl -sS "${BASE_URL}/v1/models" "${AUTH_HEADER[@]}" | sed -n '1,8p'
curl -fsS "${BASE_URL}/v1/models" | head -c 200
echo ""
echo "[3] chat/completions"
curl -sS -X POST "${BASE_URL}/v1/chat/completions" \
curl -fsS -X POST "${BASE_URL}/v1/chat/completions" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
-d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \
| sed -n '1,20p'
-d '{"model":"no-reply","messages":[{"role":"user","content":"hello"}]}' \
| head -c 300
echo ""
echo "[4] responses"
curl -sS -X POST "${BASE_URL}/v1/responses" \
curl -fsS -X POST "${BASE_URL}/v1/responses" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
-d '{"model":"whispergate-no-reply-v1","input":"hello"}' \
| sed -n '1,20p'
-d '{"model":"no-reply","input":"hello"}' \
| head -c 300
echo ""
echo "smoke ok"

211
scripts/test-features.mjs Normal file
View File

@@ -0,0 +1,211 @@
#!/usr/bin/env node
/**
* Dirigent feature test script
* Tests: no-reply gate, end-symbol enforcement, turn management
*
* Usage:
* node scripts/test-features.mjs [channelId]
*
* Env:
* PROXY_TOKEN - path to PROXY_BOT_TOKEN file (default: ./PROXY_BOT_TOKEN)
* GUILD_ID - guild id (default: 1480860737902743686)
*
* Reads token from PROXY_BOT_TOKEN file.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TOKEN_FILE = process.env.PROXY_TOKEN || path.resolve(__dirname, "../PROXY_BOT_TOKEN");
const GUILD_ID = process.env.GUILD_ID || "1480860737902743686";
const C = {
reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m",
yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m", bold: "\x1b[1m",
};
const c = (t, col) => `${C[col] || ""}${t}${C.reset}`;
const TOKEN = fs.readFileSync(TOKEN_FILE, "utf8").trim().split(/\s/)[0];
async function discord(method, path_, body) {
const r = await fetch(`https://discord.com/api/v10${path_}`, {
method,
headers: {
Authorization: `Bot ${TOKEN}`,
"Content-Type": "application/json",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let json = null;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
return { ok: r.ok, status: r.status, json };
}
async function sendMessage(channelId, content) {
const r = await discord("POST", `/channels/${channelId}/messages`, { content });
if (!r.ok) throw new Error(`send failed ${r.status}: ${JSON.stringify(r.json)}`);
return r.json;
}
async function getMessages(channelId, limit = 10) {
const r = await discord("GET", `/channels/${channelId}/messages?limit=${limit}`);
if (!r.ok) throw new Error(`fetch messages failed ${r.status}`);
return r.json; // newest first
}
async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// Wait for agent responses: poll until we see `expectedCount` new messages from bots
async function waitForBotMessages(channelId, afterMsgId, expectedCount = 1, timeoutMs = 15000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await sleep(1500);
const msgs = await getMessages(channelId, 20);
const newBotMsgs = msgs.filter(m =>
BigInt(m.id) > BigInt(afterMsgId) &&
m.author?.bot === true &&
m.author?.id !== "1481189346097758298" // exclude our proxy bot
);
if (newBotMsgs.length >= expectedCount) return newBotMsgs;
}
return [];
}
function printMsg(m) {
const who = `${m.author?.username}(${m.author?.id})`;
const preview = (m.content || "").slice(0, 120).replace(/\n/g, "\\n");
console.log(` ${c(who, "cyan")}: ${preview}`);
}
// ─────────────────────────────────────────────────────────
// Test helpers
// ─────────────────────────────────────────────────────────
let passed = 0, failed = 0;
function check(label, cond, detail = "") {
if (cond) {
console.log(` ${c("✓", "green")} ${label}`);
passed++;
} else {
console.log(` ${c("✗", "red")} ${label}${detail ? `${detail}` : ""}`);
failed++;
}
}
// ─────────────────────────────────────────────────────────
// Main tests
// ─────────────────────────────────────────────────────────
async function main() {
// Resolve channel
let channelId = process.argv[2];
if (!channelId) {
// Create a fresh test channel
console.log(c("\n[setup] Creating private test channel...", "blue"));
const meR = await discord("GET", "/users/@me");
if (!meR.ok) { console.error("Cannot auth:", meR.json); process.exit(1); }
console.log(` proxy bot: ${meR.json.username} (${meR.json.id})`);
// Get guild roles to find @everyone
const guildR = await discord("GET", `/guilds/${GUILD_ID}`);
const guildEveryoneId = guildR.json?.id || GUILD_ID;
// Get guild members to find agent bots
const membersR = await discord("GET", `/guilds/${GUILD_ID}/members?limit=50`);
const bots = (membersR.json || [])
.filter(m => m.user?.bot && m.user?.id !== meR.json.id)
.map(m => m.user);
console.log(` agent bots in guild: ${bots.map(b => `${b.username}(${b.id})`).join(", ")}`);
const allowedUserIds = [meR.json.id, ...bots.map(b => b.id)];
const overwrites = [
{ id: guildEveryoneId, type: 0, allow: "0", deny: "1024" },
...allowedUserIds.map(id => ({ id, type: 1, allow: "1024", deny: "0" })),
];
const chR = await discord("POST", `/guilds/${GUILD_ID}/channels`, {
name: `dirigent-test-${Date.now().toString(36)}`,
type: 0,
permission_overwrites: overwrites,
});
if (!chR.ok) { console.error("Cannot create channel:", chR.json); process.exit(1); }
channelId = chR.json.id;
console.log(` created channel: #${chR.json.name} (${channelId})`);
} else {
console.log(c(`\n[setup] Using channel ${channelId}`, "blue"));
}
await sleep(1000);
// ─────────────────────────────────────────────────────────
// Test 1: Human sends message → agents should respond with end-symbol
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 1] Human message → agent response must end with 🔚", "bold"));
const msg1 = await sendMessage(channelId, "Hello from human proxy! Please introduce yourself briefly. 🔚");
console.log(` sent: "${msg1.content}"`);
console.log(" waiting up to 20s for bot responses...");
const botMsgs1 = await waitForBotMessages(channelId, msg1.id, 1, 20000);
if (botMsgs1.length === 0) {
check("Agent responded", false, "no bot messages received within 20s");
} else {
for (const m of botMsgs1) printMsg(m);
check(
"Agent response ends with 🔚",
botMsgs1.some(m => m.content?.trim().endsWith("🔚")),
`got: ${botMsgs1.map(m => m.content?.slice(-10)).join(" | ")}`
);
}
// ─────────────────────────────────────────────────────────
// Test 2: Turn order — only one agent per round
// After first agent replies, second agent should be next
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 2] Turn order — check /dirigent turn-status", "bold"));
console.log(" (Observational — check Discord channel for /dirigent turn-status output)");
console.log(c(" → Manually run /dirigent turn-status in the test channel to verify", "yellow"));
// ─────────────────────────────────────────────────────────
// Test 3: Bot message without end-symbol → no-reply gate
// We send a message that looks like a bot (not in humanList) — observe logs
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 3] Second round — agent should reply after human follow-up", "bold"));
await sleep(3000);
const msg3 = await sendMessage(channelId, "What is 2+2? Answer briefly. 🔚");
console.log(` sent: "${msg3.content}"`);
const botMsgs3 = await waitForBotMessages(channelId, msg3.id, 1, 20000);
if (botMsgs3.length === 0) {
check("Agent responded to follow-up", false, "no response within 20s");
} else {
for (const m of botMsgs3) printMsg(m);
check(
"Follow-up response ends with 🔚",
botMsgs3.some(m => m.content?.trim().endsWith("🔚")),
);
}
// ─────────────────────────────────────────────────────────
// Test 4: NO_REPLY behavior — ask something irrelevant to trigger NO_REPLY
// ─────────────────────────────────────────────────────────
console.log(c("\n[Test 4] NO_REPLY — agents with nothing to say should be silent", "bold"));
console.log(" (This is hard to assert automatically — check gateway logs for NO_REPLY routing)");
console.log(c(" → Watch `openclaw logs` for 'dirigent: before_model_resolve blocking out-of-turn'", "yellow"));
// ─────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────
console.log(c(`\n─────────────────────────────────────────────`, "blue"));
console.log(`Results: ${c(String(passed), "green")} passed, ${c(String(failed), "red")} failed`);
console.log(`Channel: https://discord.com/channels/${GUILD_ID}/${channelId}`);
console.log(c("─────────────────────────────────────────────\n", "blue"));
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -1,82 +0,0 @@
import { spawn } from "node:child_process";
const BASE = "http://127.0.0.1:18787";
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function waitForHealth(retries = 30) {
for (let i = 0; i < retries; i++) {
try {
const r = await fetch(`${BASE}/health`);
if (r.ok) return true;
} catch {}
await sleep(200);
}
return false;
}
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
async function run() {
const token = "test-token";
const child = spawn("node", ["no-reply-api/server.mjs"], {
cwd: process.cwd(),
env: { ...process.env, PORT: "18787", AUTH_TOKEN: token, NO_REPLY_MODEL: "wg-test-model" },
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", () => {});
child.stderr.on("data", () => {});
try {
const ok = await waitForHealth();
assert(ok, "health check failed");
const unauth = await fetch(`${BASE}/v1/models`);
assert(unauth.status === 401, `expected 401, got ${unauth.status}`);
const models = await fetch(`${BASE}/v1/models`, {
headers: { Authorization: `Bearer ${token}` },
});
assert(models.ok, "authorized /v1/models failed");
const modelsJson = await models.json();
assert(modelsJson?.data?.[0]?.id === "wg-test-model", "model id mismatch");
const cc = await fetch(`${BASE}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model: "wg-test-model", messages: [{ role: "user", content: "hi" }] }),
});
assert(cc.ok, "chat completions failed");
const ccJson = await cc.json();
assert(ccJson?.choices?.[0]?.message?.content === "NO_REPLY", "chat completion not NO_REPLY");
const rsp = await fetch(`${BASE}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model: "wg-test-model", input: "hi" }),
});
assert(rsp.ok, "responses failed");
const rspJson = await rsp.json();
assert(rspJson?.output?.[0]?.content?.[0]?.text === "NO_REPLY", "responses not NO_REPLY");
console.log("test-no-reply-api: ok");
} finally {
child.kill("SIGTERM");
}
}
run().catch((err) => {
console.error(`test-no-reply-api: fail: ${err.message}`);
process.exit(1);
});

111
services/main.mjs Normal file
View File

@@ -0,0 +1,111 @@
/**
* Unified entry point for Dirigent services.
*
* Routes:
* /no-reply/* → no-reply API (strips /no-reply prefix)
* /moderator/* → moderator bot service (strips /moderator prefix)
* otherwise → 404
*
* Env vars:
* SERVICES_PORT (default 8787)
* MODERATOR_TOKEN Discord bot token (required for moderator)
* PLUGIN_API_URL (default http://127.0.0.1:18789)
* PLUGIN_API_TOKEN auth token for plugin API calls
* SCHEDULE_IDENTIFIER (default ➡️)
* DEBUG_MODE (default false)
*/
import http from "node:http";
import { createNoReplyHandler } from "./no-reply-api/server.mjs";
import { createModeratorService } from "./moderator/index.mjs";
const PORT = Number(process.env.SERVICES_PORT || 8787);
const MODERATOR_TOKEN = process.env.MODERATOR_TOKEN || "";
const PLUGIN_API_URL = process.env.PLUGIN_API_URL || "http://127.0.0.1:18789";
const PLUGIN_API_TOKEN = process.env.PLUGIN_API_TOKEN || "";
const SCHEDULE_IDENTIFIER = process.env.SCHEDULE_IDENTIFIER || "➡️";
const DEBUG_MODE = process.env.DEBUG_MODE === "true" || process.env.DEBUG_MODE === "1";
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
// ── Initialize services ────────────────────────────────────────────────────────
const noReplyHandler = createNoReplyHandler();
let moderatorService = null;
if (MODERATOR_TOKEN) {
console.log("[dirigent-services] moderator bot enabled");
moderatorService = createModeratorService({
token: MODERATOR_TOKEN,
pluginApiUrl: PLUGIN_API_URL,
pluginApiToken: PLUGIN_API_TOKEN,
scheduleIdentifier: SCHEDULE_IDENTIFIER,
debugMode: DEBUG_MODE,
});
} else {
console.log("[dirigent-services] MODERATOR_TOKEN not set — moderator disabled");
}
// ── HTTP server ────────────────────────────────────────────────────────────────
const server = http.createServer((req, res) => {
const url = req.url ?? "/";
if (url === "/health") {
return sendJson(res, 200, {
ok: true,
services: {
noReply: true,
moderator: !!moderatorService,
},
});
}
if (url.startsWith("/no-reply")) {
req.url = url.slice("/no-reply".length) || "/";
return noReplyHandler(req, res);
}
if (url.startsWith("/moderator")) {
if (!moderatorService) {
return sendJson(res, 503, { error: "moderator service not configured" });
}
req.url = url.slice("/moderator".length) || "/";
return moderatorService.httpHandler(req, res);
}
return sendJson(res, 404, { error: "not_found" });
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`[dirigent-services] listening on 127.0.0.1:${PORT}`);
console.log(`[dirigent-services] /no-reply → no-reply API`);
if (moderatorService) {
console.log(`[dirigent-services] /moderator → moderator bot`);
console.log(`[dirigent-services] plugin API: ${PLUGIN_API_URL}`);
}
if (DEBUG_MODE) {
console.log(`[dirigent-services] debug mode ON`);
}
});
// ── Graceful shutdown ──────────────────────────────────────────────────────────
function shutdown(signal) {
console.log(`[dirigent-services] received ${signal}, shutting down`);
if (moderatorService) {
moderatorService.stop();
}
server.close(() => {
console.log("[dirigent-services] server closed");
process.exit(0);
});
// Force-exit after 5s
setTimeout(() => process.exit(1), 5000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

View File

@@ -0,0 +1,514 @@
/**
* Moderator bot service.
*
* Exports createModeratorService(config) returning { httpHandler(req, res), stop() }.
*
* Responsibilities:
* - Discord Gateway WS with intents GUILD_MESSAGES (512) | MESSAGE_CONTENT (32768)
* - On MESSAGE_CREATE dispatch: notify plugin API
* - HTTP sub-handler for /health, /me, /send, /delete-message, /create-channel, /guilds, /channels/:guildId
*/
import { URL as NodeURL } from "node:url";
const DISCORD_API = "https://discord.com/api/v10";
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
const MAX_RECONNECT_DELAY_MS = 60_000;
const INTENTS = 512 | 32768; // GUILD_MESSAGES | MESSAGE_CONTENT
// ── Helpers ────────────────────────────────────────────────────────────────────
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
req.destroy();
reject(new Error("body too large"));
}
});
req.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("invalid_json"));
}
});
req.on("error", reject);
});
}
function getBotUserIdFromToken(token) {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}
// ── Discord REST helpers ───────────────────────────────────────────────────────
async function discordGet(token, path) {
const r = await fetch(`${DISCORD_API}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Discord GET ${path} failed (${r.status}): ${text}`);
}
return r.json();
}
async function discordPost(token, path, body) {
const r = await fetch(`${DISCORD_API}${path}`, {
method: "POST",
headers: {
Authorization: `Bot ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return { ok: r.ok, status: r.status, data: await r.json().catch(() => null) };
}
async function discordDelete(token, path) {
const r = await fetch(`${DISCORD_API}${path}`, {
method: "DELETE",
headers: { Authorization: `Bot ${token}` },
});
return { ok: r.ok, status: r.status };
}
// ── Gateway connection ─────────────────────────────────────────────────────────
function createGatewayConnection(token, onMessage, log) {
let ws = null;
let heartbeatTimer = null;
let heartbeatAcked = true;
let lastSequence = null;
let sessionId = null;
let resumeUrl = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
let destroyed = false;
function sendPayload(data) {
if (ws?.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify(data));
}
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
clearTimeout(heartbeatTimer);
heartbeatTimer = null;
}
}
function startHeartbeat(intervalMs) {
stopHeartbeat();
heartbeatAcked = true;
const jitter = Math.floor(Math.random() * intervalMs);
const firstTimer = setTimeout(() => {
if (destroyed) return;
if (!heartbeatAcked) {
ws?.close(4000, "missed heartbeat ack");
return;
}
heartbeatAcked = false;
sendPayload({ op: 1, d: lastSequence });
heartbeatTimer = setInterval(() => {
if (destroyed) return;
if (!heartbeatAcked) {
ws?.close(4000, "missed heartbeat ack");
return;
}
heartbeatAcked = false;
sendPayload({ op: 1, d: lastSequence });
}, intervalMs);
}, jitter);
heartbeatTimer = firstTimer;
}
function cleanup() {
stopHeartbeat();
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
try { ws.close(1000); } catch { /* ignore */ }
ws = null;
}
}
function scheduleReconnect(resume) {
if (destroyed) return;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectAttempts++;
const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS);
const delay = baseDelay + Math.random() * 1000;
log.info(`dirigent-moderator: reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect(resume);
}, delay);
}
function connect(isResume = false) {
if (destroyed) return;
cleanup();
const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL;
try {
ws = new WebSocket(url);
} catch (err) {
log.warn(`dirigent-moderator: ws constructor failed: ${String(err)}`);
scheduleReconnect(false);
return;
}
const currentWs = ws;
ws.onopen = () => {
if (currentWs !== ws || destroyed) return;
reconnectAttempts = 0;
if (isResume && sessionId) {
sendPayload({
op: 6,
d: { token, session_id: sessionId, seq: lastSequence },
});
} else {
sendPayload({
op: 2,
d: {
token,
intents: INTENTS,
properties: {
os: "linux",
browser: "dirigent",
device: "dirigent",
},
},
});
}
};
ws.onmessage = (evt) => {
if (currentWs !== ws || destroyed) return;
try {
const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
const { op, t, s, d } = msg;
if (s != null) lastSequence = s;
switch (op) {
case 10: // Hello
startHeartbeat(d.heartbeat_interval);
break;
case 11: // Heartbeat ACK
heartbeatAcked = true;
break;
case 1: // Heartbeat request
sendPayload({ op: 1, d: lastSequence });
break;
case 0: // Dispatch
if (t === "READY") {
sessionId = d.session_id;
resumeUrl = d.resume_gateway_url;
log.info("dirigent-moderator: connected and ready");
} else if (t === "RESUMED") {
log.info("dirigent-moderator: session resumed");
} else if (t === "MESSAGE_CREATE") {
onMessage(d);
}
break;
case 7: // Reconnect
log.info("dirigent-moderator: reconnect requested by Discord");
cleanup();
scheduleReconnect(true);
break;
case 9: // Invalid Session
log.warn(`dirigent-moderator: invalid session, resumable=${d}`);
cleanup();
sessionId = d ? sessionId : null;
setTimeout(() => {
if (!destroyed) connect(!!d && !!sessionId);
}, 3000 + Math.random() * 2000);
break;
}
} catch {
// ignore parse errors
}
};
ws.onclose = (evt) => {
if (currentWs !== ws) return;
stopHeartbeat();
if (destroyed) return;
const code = evt.code;
if (code === 4004) {
log.warn("dirigent-moderator: token invalid (4004), stopping");
return;
}
if (code === 4010 || code === 4011 || code === 4013 || code === 4014) {
log.warn(`dirigent-moderator: fatal close (${code}), re-identifying`);
sessionId = null;
scheduleReconnect(false);
return;
}
log.info(`dirigent-moderator: disconnected (code=${code}), will reconnect`);
const canResume = !!sessionId && code !== 4012;
scheduleReconnect(canResume);
};
ws.onerror = () => {
// onclose will fire after this
};
}
// Start initial connection
connect(false);
return {
stop() {
destroyed = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
cleanup();
},
};
}
// ── HTTP route handler ─────────────────────────────────────────────────────────
function createHttpHandler(token, botUserId, log) {
return async function httpHandler(req, res) {
const url = req.url ?? "/";
// GET /health
if (req.method === "GET" && url === "/health") {
return sendJson(res, 200, { ok: true, botId: botUserId });
}
// GET /me
if (req.method === "GET" && url === "/me") {
try {
const data = await discordGet(token, "/users/@me");
return sendJson(res, 200, { id: data.id, username: data.username });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// GET /guilds
if (req.method === "GET" && url === "/guilds") {
try {
const guilds = await discordGet(token, "/users/@me/guilds");
const ADMIN = 8n;
const adminGuilds = guilds
.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN)
.map((g) => ({ id: g.id, name: g.name }));
return sendJson(res, 200, { guilds: adminGuilds });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// GET /channels/:guildId
const channelsMatch = url.match(/^\/channels\/(\d+)$/);
if (req.method === "GET" && channelsMatch) {
const guildId = channelsMatch[1];
try {
const channels = await discordGet(token, `/guilds/${guildId}/channels`);
return sendJson(res, 200, {
channels: channels
.filter((c) => c.type === 0)
.map((c) => ({ id: c.id, name: c.name, type: c.type })),
});
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// POST /send
if (req.method === "POST" && url === "/send") {
let body;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const { channelId, content } = body;
if (!channelId || !content) {
return sendJson(res, 400, { ok: false, error: "channelId and content required" });
}
try {
const result = await discordPost(token, `/channels/${channelId}/messages`, { content });
if (!result.ok) {
return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` });
}
return sendJson(res, 200, { ok: true, messageId: result.data?.id });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// POST /delete-message
if (req.method === "POST" && url === "/delete-message") {
let body;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const { channelId, messageId } = body;
if (!channelId || !messageId) {
return sendJson(res, 400, { ok: false, error: "channelId and messageId required" });
}
try {
const result = await discordDelete(token, `/channels/${channelId}/messages/${messageId}`);
return sendJson(res, 200, { ok: result.ok });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
// POST /create-channel
if (req.method === "POST" && url === "/create-channel") {
let body;
try {
body = await readBody(req);
} catch (err) {
return sendJson(res, 400, { ok: false, error: String(err) });
}
const { guildId, name, permissionOverwrites = [] } = body;
if (!guildId || !name) {
return sendJson(res, 400, { ok: false, error: "guildId and name required" });
}
try {
const result = await discordPost(token, `/guilds/${guildId}/channels`, {
name,
type: 0,
permission_overwrites: permissionOverwrites,
});
if (!result.ok) {
return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` });
}
return sendJson(res, 200, { ok: true, channelId: result.data?.id });
} catch (err) {
return sendJson(res, 500, { ok: false, error: String(err) });
}
}
return sendJson(res, 404, { error: "not_found" });
};
}
// ── Plugin notification ────────────────────────────────────────────────────────
function createNotifyPlugin(pluginApiUrl, pluginApiToken, log) {
return function notifyPlugin(message) {
const body = JSON.stringify({
channelId: message.channel_id,
messageId: message.id,
senderId: message.author?.id,
guildId: message.guild_id,
content: message.content,
});
const headers = {
"Content-Type": "application/json",
};
if (pluginApiToken) {
headers["Authorization"] = `Bearer ${pluginApiToken}`;
}
fetch(`${pluginApiUrl}/dirigent/api/moderator/message`, {
method: "POST",
headers,
body,
}).catch((err) => {
log.warn(`dirigent-moderator: notify plugin failed: ${String(err)}`);
});
};
}
// ── Public API ─────────────────────────────────────────────────────────────────
/**
* Create the moderator service.
*
* @param {object} config
* @param {string} config.token - Discord bot token
* @param {string} config.pluginApiUrl - e.g. "http://127.0.0.1:18789"
* @param {string} [config.pluginApiToken] - bearer token for plugin API
* @param {string} [config.scheduleIdentifier] - e.g. "➡️"
* @param {boolean} [config.debugMode]
* @returns {{ httpHandler: Function, stop: Function }}
*/
export function createModeratorService(config) {
const { token, pluginApiUrl, pluginApiToken = "", scheduleIdentifier = "➡️", debugMode = false } = config;
const log = {
info: (msg) => console.log(`[dirigent-moderator] ${msg}`),
warn: (msg) => console.warn(`[dirigent-moderator] WARN ${msg}`),
};
if (debugMode) {
log.info(`debug mode enabled, scheduleIdentifier=${scheduleIdentifier}`);
}
// Decode bot user ID from token
const botUserId = getBotUserIdFromToken(token);
log.info(`bot user id decoded: ${botUserId ?? "(unknown)"}`);
// Plugin notify callback (fire-and-forget)
const notifyPlugin = createNotifyPlugin(pluginApiUrl, pluginApiToken, log);
// Gateway connection
const gateway = createGatewayConnection(
token,
(message) => {
// Skip bot's own messages
if (message.author?.id === botUserId) return;
notifyPlugin(message);
},
log,
);
// HTTP handler (caller strips /moderator prefix)
const httpHandler = createHttpHandler(token, botUserId, log);
return {
httpHandler,
stop() {
log.info("stopping moderator service");
gateway.stop();
},
};
}

View File

@@ -0,0 +1,131 @@
import http from "node:http";
const modelName = process.env.NO_REPLY_MODEL || "no-reply";
const authToken = process.env.AUTH_TOKEN || "";
function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
function isAuthorized(req) {
if (!authToken) return true;
const header = req.headers.authorization || "";
return header === `Bearer ${authToken}`;
}
function noReplyChatCompletion(reqBody) {
return {
id: `chatcmpl_dirigent_${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
choices: [
{
index: 0,
message: { role: "assistant", content: "NO_REPLY" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 },
};
}
function noReplyResponses(reqBody) {
return {
id: `resp_dirigent_${Date.now()}`,
object: "response",
created_at: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName,
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "NO_REPLY" }],
},
],
usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 },
};
}
function listModels() {
return {
object: "list",
data: [
{
id: modelName,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "dirigent",
},
],
};
}
/**
* Returns a Node.js HTTP request handler for the no-reply API.
* When used as a sub-service inside main.mjs, the caller strips
* the "/no-reply" prefix from req.url before calling this handler.
*/
export function createNoReplyHandler() {
return function noReplyHandler(req, res) {
const url = req.url ?? "/";
if (req.method === "GET" && url === "/health") {
return sendJson(res, 200, {
ok: true,
service: "dirigent-no-reply-api",
model: modelName,
});
}
if (req.method === "GET" && url === "/v1/models") {
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
return sendJson(res, 200, listModels());
}
if (req.method !== "POST") {
return sendJson(res, 404, { error: "not_found" });
}
if (!isAuthorized(req)) {
return sendJson(res, 401, { error: "unauthorized" });
}
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) req.destroy();
});
req.on("end", () => {
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch {
return sendJson(res, 400, { error: "invalid_json" });
}
if (url === "/v1/chat/completions") {
return sendJson(res, 200, noReplyChatCompletion(parsed));
}
if (url === "/v1/responses") {
return sendJson(res, 200, noReplyResponses(parsed));
}
return sendJson(res, 404, { error: "not_found" });
});
};
}
// Standalone mode: run HTTP server if this file is the entry point
const isMain = process.argv[1] && process.argv[1].endsWith("server.mjs");
if (isMain) {
const port = Number(process.env.PORT || 8787);
const handler = createNoReplyHandler();
const server = http.createServer(handler);
server.listen(port, () => {
console.log(`[dirigent-no-reply-api] listening on :${port}`);
});
}

View File

@@ -0,0 +1,29 @@
---
name: discord-guilds
description: When calling tools that require Discord guild ID/server ID, refer to this skill for available guild IDs and their descriptions.
---
# Discord Guilds
Use this skill when a tool or command requires a Discord `guildId` (also called server ID).
## Available Guilds
| guild-id | description |
|----------|-------------|
| 1480860737902743686 | Main test guild for HarborForge/Dirigent development |
## Usage
When calling tools like `dirigent_discord_control` that require a `guildId` parameter, look up the appropriate guild ID from the table above based on the context.
To add a new guild to this list, use the `add-guild` script:
```bash
{baseDir}/scripts/add-guild <guild-id> <description>
```
Example:
```bash
~/.openclaw/skills/discord-guilds/scripts/add-guild "123456789012345678" "Production server"
```

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILL_PATH = path.resolve(__dirname, "../SKILL.md");
const args = process.argv.slice(2);
if (args.length < 2) {
console.error("Usage: add-guild <guild-id> <description>");
process.exit(1);
}
const guildId = args[0];
const description = args.slice(1).join(" ");
if (!/^\d+$/.test(guildId)) {
console.error("Error: guild-id must be numeric (Discord snowflake)");
process.exit(1);
}
let content = fs.readFileSync(SKILL_PATH, "utf8");
const newRow = `| ${guildId} | ${description} |`;
// Find separator line and insert after it
const lines = content.split("\n");
let insertIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\|[-\s|]+\|$/.test(lines[i]) && i > 0 && lines[i-1].includes("guild-id")) {
insertIndex = i;
break;
}
}
if (insertIndex === -1) {
console.error("Error: Could not find guild table in SKILL.md");
process.exit(1);
}
lines.splice(insertIndex + 1, 0, newRow);
fs.writeFileSync(SKILL_PATH, lines.join("\n"), "utf8");
console.log(`✓ Added guild: ${guildId} - ${description}`);

View File

@@ -0,0 +1,175 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerBeforeMessageWriteHook } from '../plugin/hooks/before-message-write.ts';
import { registerMessageReceivedHook } from '../plugin/hooks/message-received.ts';
import { registerMessageSentHook } from '../plugin/hooks/message-sent.ts';
import { buildDiscussionOriginCallbackMessage } from '../plugin/core/discussion-messages.ts';
import { initTurnOrder, onNewMessage, getTurnDebugInfo, resetTurn } from '../plugin/turn-manager.ts';
type Handler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => unknown;
function makeApi() {
const handlers = new Map<string, Handler>();
return {
handlers,
logger: {
info: (_msg: string) => {},
warn: (_msg: string) => {},
},
on(name: string, handler: Handler) {
handlers.set(name, handler);
},
};
}
test('before_message_write leaves ordinary channels dormant without sending a discussion idle reminder', async () => {
const channelId = 'normal-channel';
resetTurn(channelId);
initTurnOrder(channelId, ['agent-a', 'agent-b']);
onNewMessage(channelId, 'human-user', true);
const state = getTurnDebugInfo(channelId);
const [firstSpeaker, secondSpeaker] = state.turnOrder as string[];
assert.ok(firstSpeaker);
assert.ok(secondSpeaker);
const sessionChannelId = new Map<string, string>([
['sess-a', channelId],
['sess-b', channelId],
]);
const sessionAccountId = new Map<string, string>([
['sess-a', firstSpeaker],
['sess-b', secondSpeaker],
]);
const sessionAllowed = new Map<string, boolean>([
['sess-a', true],
['sess-b', true],
]);
const sessionTurnHandled = new Set<string>();
const idleReminderCalls: string[] = [];
const moderatorMessages: string[] = [];
const api = makeApi();
registerBeforeMessageWriteHook({
api: api as any,
baseConfig: { endSymbols: ['🔚'], moderatorBotToken: 'bot-token' } as any,
policyState: { channelPolicies: {} },
sessionAllowed,
sessionChannelId,
sessionAccountId,
sessionTurnHandled,
ensurePolicyStateLoaded: () => {},
shouldDebugLog: () => false,
ensureTurnOrder: () => {},
resolveDiscordUserId: () => undefined,
isMultiMessageMode: () => false,
sendModeratorMessage: async (_token, _channelId, content) => {
moderatorMessages.push(content);
return { ok: true };
},
discussionService: {
maybeSendIdleReminder: async (id) => {
idleReminderCalls.push(id);
},
getDiscussion: () => undefined,
},
});
const beforeMessageWrite = api.handlers.get('before_message_write');
assert.ok(beforeMessageWrite);
await beforeMessageWrite?.({ message: { role: 'assistant', content: 'NO_REPLY' } }, { sessionKey: 'sess-a' });
await beforeMessageWrite?.({ message: { role: 'assistant', content: 'NO_REPLY' } }, { sessionKey: 'sess-b' });
assert.deepEqual(idleReminderCalls, []);
assert.deepEqual(moderatorMessages, []);
assert.equal(getTurnDebugInfo(channelId).dormant, true);
});
test('message_received lets moderator discussion callback notifications wake the origin channel workflow', async () => {
const channelId = '1474327736242798612';
resetTurn(channelId);
initTurnOrder(channelId, ['agent-a', 'agent-b']);
assert.equal(getTurnDebugInfo(channelId).currentSpeaker, null);
const api = makeApi();
registerMessageReceivedHook({
api: api as any,
baseConfig: {
moderatorUserId: 'moderator-user',
humanList: ['human-user'],
} as any,
shouldDebugLog: () => false,
debugCtxSummary: () => ({}),
ensureTurnOrder: () => {},
getModeratorUserId: (cfg) => (cfg as any).moderatorUserId,
recordChannelAccount: () => false,
extractMentionedUserIds: () => [],
buildUserIdToAccountIdMap: () => new Map(),
enterMultiMessageMode: () => {},
exitMultiMessageMode: () => {},
discussionService: {
maybeReplyClosedChannel: async () => false,
},
});
const messageReceived = api.handlers.get('message_received');
assert.ok(messageReceived);
await messageReceived?.({
content: buildDiscussionOriginCallbackMessage('/workspace/plans/discussion-summary.md', 'discussion-42'),
from: 'moderator-user',
}, {
conversationId: channelId,
});
const state = getTurnDebugInfo(channelId);
assert.equal(state.currentSpeaker, state.turnOrder[0]);
assert.equal(state.dormant, false);
});
test('message_sent skips handoff after discuss-callback has closed the discussion channel', async () => {
const channelId = 'discussion-closed-channel';
resetTurn(channelId);
initTurnOrder(channelId, ['agent-a', 'agent-b']);
onNewMessage(channelId, 'human-user', true);
const state = getTurnDebugInfo(channelId);
const currentSpeaker = state.currentSpeaker as string;
assert.ok(currentSpeaker);
const moderatorMessages: string[] = [];
const api = makeApi();
registerMessageSentHook({
api: api as any,
baseConfig: {
endSymbols: ['🔚'],
moderatorBotToken: 'bot-token',
schedulingIdentifier: '➡️',
} as any,
policyState: { channelPolicies: {} },
sessionChannelId: new Map([['sess-closed', channelId]]),
sessionAccountId: new Map([['sess-closed', currentSpeaker]]),
sessionTurnHandled: new Set(),
ensurePolicyStateLoaded: () => {},
resolveDiscordUserId: () => 'discord-user-next',
sendModeratorMessage: async (_token, _channelId, content) => {
moderatorMessages.push(content);
return { ok: true };
},
discussionService: {
isClosedDiscussion: (id) => id === channelId,
},
});
const messageSent = api.handlers.get('message_sent');
assert.ok(messageSent);
await messageSent?.({ content: 'NO_REPLY' }, { sessionKey: 'sess-closed', accountId: currentSpeaker, channelId });
assert.deepEqual(moderatorMessages, []);
assert.equal(getTurnDebugInfo(channelId).currentSpeaker, currentSpeaker);
});

View File

@@ -0,0 +1,420 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createDiscussionService } from '../plugin/core/discussion-service.ts';
import { buildDiscussionClosedMessage, buildDiscussionIdleReminderMessage, buildDiscussionOriginCallbackMessage } from '../plugin/core/discussion-messages.ts';
function makeLogger() {
return {
info: (_msg: string) => {},
warn: (_msg: string) => {},
};
}
function makeApi() {
return {
logger: makeLogger(),
};
}
function makeWorkspace(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dirigent-discussion-test-'));
}
test('initDiscussion stores metadata and getDiscussion retrieves it by channel id', async () => {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: makeWorkspace(),
forceNoReplyForSession: () => {},
});
const metadata = await service.initDiscussion({
discussionChannelId: 'discussion-init-1',
originChannelId: 'origin-1',
initiatorAgentId: 'agent-alpha',
initiatorSessionId: 'session-alpha',
discussGuide: 'Settle the callback contract.',
});
assert.equal(metadata.mode, 'discussion');
assert.equal(metadata.status, 'active');
assert.equal(metadata.originChannelId, 'origin-1');
const stored = service.getDiscussion('discussion-init-1');
assert.ok(stored);
assert.equal(stored?.discussionChannelId, 'discussion-init-1');
assert.equal(stored?.initiatorAgentId, 'agent-alpha');
assert.equal(stored?.initiatorSessionId, 'session-alpha');
});
test('handleCallback closes an active discussion and records the resolved summary path', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('plans', 'summary.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, '# summary\n');
const forcedSessions: string[] = [];
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: (sessionKey) => forcedSessions.push(sessionKey),
getDiscussionSessionKeys: () => ['session-beta-helper'],
});
await service.initDiscussion({
discussionChannelId: 'discussion-close-1',
originChannelId: 'origin-2',
initiatorAgentId: 'agent-beta',
initiatorSessionId: 'session-beta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Write the wrap-up.',
});
const result = await service.handleCallback({
channelId: 'discussion-close-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-beta',
callerSessionKey: 'session-beta',
});
assert.equal(result.ok, true);
assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath));
assert.equal(result.discussion.status, 'closed');
assert.equal(result.discussion.summaryPath, fs.realpathSync.native(summaryAbsPath));
assert.ok(result.discussion.completedAt);
assert.deepEqual(forcedSessions.sort(), ['session-beta', 'session-beta-helper']);
});
test('handleCallback rejects duplicate callback after the discussion is already closed', async () => {
const workspace = makeWorkspace();
const summaryRelPath = 'summary.md';
fs.writeFileSync(path.join(workspace, summaryRelPath), 'done\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-duplicate-1',
originChannelId: 'origin-3',
initiatorAgentId: 'agent-gamma',
initiatorSessionId: 'session-gamma',
discussGuide: 'One close only.',
});
await service.handleCallback({
channelId: 'discussion-duplicate-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-gamma',
callerSessionKey: 'session-gamma',
});
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-duplicate-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-gamma',
callerSessionKey: 'session-gamma',
}),
/discussion is already closed/,
);
});
test('handleCallback accepts a valid summaryPath inside the initiator workspace', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('notes', 'nested', 'summary.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, 'nested summary\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-path-ok-1',
originChannelId: 'origin-4',
initiatorAgentId: 'agent-delta',
initiatorSessionId: 'session-delta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Path validation.',
});
const result = await service.handleCallback({
channelId: 'discussion-path-ok-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-delta',
callerSessionKey: 'session-delta',
});
assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath));
});
test('handleCallback rejects a missing summary file', async () => {
const workspace = makeWorkspace();
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-missing-1',
originChannelId: 'origin-5',
initiatorAgentId: 'agent-epsilon',
initiatorSessionId: 'session-epsilon',
discussGuide: 'Expect missing file failure.',
});
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-missing-1',
summaryPath: 'missing.md',
callerAgentId: 'agent-epsilon',
callerSessionKey: 'session-epsilon',
}),
);
});
test('handleCallback uses the initiator workspace root instead of the process cwd', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('notes', 'initiator-only.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, 'initiator workspace file\n');
const differentDefaultWorkspace = makeWorkspace();
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: differentDefaultWorkspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-workspace-root-1',
originChannelId: 'origin-6a',
initiatorAgentId: 'agent-zeta-root',
initiatorSessionId: 'session-zeta-root',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Use initiator workspace root.',
});
const result = await service.handleCallback({
channelId: 'discussion-workspace-root-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-zeta-root',
callerSessionKey: 'session-zeta-root',
});
assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath));
});
test('handleCallback rejects .. path traversal outside the initiator workspace', async () => {
const workspace = makeWorkspace();
const outsideDir = makeWorkspace();
const outsideFile = path.join(outsideDir, 'outside.md');
fs.writeFileSync(outsideFile, 'outside\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-traversal-1',
originChannelId: 'origin-6',
initiatorAgentId: 'agent-zeta',
initiatorSessionId: 'session-zeta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Reject traversal.',
});
const traversalPath = path.relative(workspace, outsideFile);
assert.match(traversalPath, /^\.\./);
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-traversal-1',
summaryPath: traversalPath,
callerAgentId: 'agent-zeta',
callerSessionKey: 'session-zeta',
}),
/summaryPath must stay inside the initiator workspace/,
);
});
test('handleCallback rejects an absolute path outside the initiator workspace', async () => {
const workspace = makeWorkspace();
const outsideDir = makeWorkspace();
const outsideFile = path.join(outsideDir, 'absolute-outside.md');
fs.writeFileSync(outsideFile, 'absolute outside\n');
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-absolute-1',
originChannelId: 'origin-7',
initiatorAgentId: 'agent-eta',
initiatorSessionId: 'session-eta',
initiatorWorkspaceRoot: workspace,
discussGuide: 'Reject absolute outside path.',
});
await assert.rejects(
() => service.handleCallback({
channelId: 'discussion-absolute-1',
summaryPath: outsideFile,
callerAgentId: 'agent-eta',
callerSessionKey: 'session-eta',
}),
/summaryPath must stay inside the initiator workspace/,
);
});
test('maybeSendIdleReminder sends exactly one idle reminder for an active discussion', async () => {
const workspace = makeWorkspace();
const fetchCalls: Array<{ url: string; body: any }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
fetchCalls.push({ url: String(url), body });
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
}) as typeof fetch;
try {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
moderatorBotToken: 'bot-token',
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-idle-1',
originChannelId: 'origin-idle-1',
initiatorAgentId: 'agent-idle',
initiatorSessionId: 'session-idle',
discussGuide: 'Only send one reminder.',
});
await service.maybeSendIdleReminder('discussion-idle-1');
await service.maybeSendIdleReminder('discussion-idle-1');
assert.equal(fetchCalls.length, 2);
assert.equal(fetchCalls[1]?.url, 'https://discord.com/api/v10/channels/discussion-idle-1/messages');
assert.equal(fetchCalls[1]?.body?.content, buildDiscussionIdleReminderMessage());
} finally {
globalThis.fetch = originalFetch;
}
});
test('handleCallback notifies the origin channel with the resolved summary path', async () => {
const workspace = makeWorkspace();
const summaryRelPath = path.join('plans', 'discussion-summary.md');
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
fs.writeFileSync(summaryAbsPath, '# done\n');
const fetchCalls: Array<{ url: string; body: any }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
fetchCalls.push({ url: String(url), body });
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
}) as typeof fetch;
try {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
moderatorBotToken: 'bot-token',
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-origin-1',
originChannelId: 'origin-8',
initiatorAgentId: 'agent-theta',
initiatorSessionId: 'session-theta',
discussGuide: 'Notify the origin channel.',
});
await service.handleCallback({
channelId: 'discussion-origin-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-theta',
callerSessionKey: 'session-theta',
});
assert.equal(fetchCalls.length, 2);
assert.equal(fetchCalls[1]?.url, 'https://discord.com/api/v10/channels/origin-8/messages');
assert.equal(
fetchCalls[1]?.body?.content,
buildDiscussionOriginCallbackMessage(fs.realpathSync.native(summaryAbsPath), 'discussion-origin-1'),
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('maybeReplyClosedChannel sends the archive-only closed message for later channel activity', async () => {
const workspace = makeWorkspace();
const summaryRelPath = 'summary.md';
const summaryAbsPath = path.join(workspace, summaryRelPath);
fs.writeFileSync(summaryAbsPath, 'closed\n');
const fetchCalls: Array<{ url: string; body: any }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
fetchCalls.push({ url: String(url), body });
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
}) as typeof fetch;
try {
const service = createDiscussionService({
api: makeApi() as any,
workspaceRoot: workspace,
moderatorBotToken: 'bot-token',
moderatorUserId: 'moderator-user',
forceNoReplyForSession: () => {},
});
await service.initDiscussion({
discussionChannelId: 'discussion-closed-1',
originChannelId: 'origin-9',
initiatorAgentId: 'agent-iota',
initiatorSessionId: 'session-iota',
discussGuide: 'Close and archive.',
});
await service.handleCallback({
channelId: 'discussion-closed-1',
summaryPath: summaryRelPath,
callerAgentId: 'agent-iota',
callerSessionKey: 'session-iota',
});
const handled = await service.maybeReplyClosedChannel('discussion-closed-1', 'human-user');
assert.equal(handled, true);
assert.equal(fetchCalls.length, 3);
assert.equal(fetchCalls[2]?.url, 'https://discord.com/api/v10/channels/discussion-closed-1/messages');
assert.equal(fetchCalls[2]?.body?.content, buildDiscussionClosedMessage());
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,141 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode, setChannelShuffling, getChannelShuffling } from "../plugin/core/channel-modes.ts";
import { initTurnOrder, checkTurn, getTurnDebugInfo, onNewMessage, resetTurn, setWaitingForHuman, isWaitingForHuman } from "../plugin/turn-manager.ts";
describe("Mode Compatibility Tests", () => {
const channelId = "test-channel";
beforeEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId); // Ensure clean state
});
afterEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId);
});
describe("multi-message mode with waiting-for-human", () => {
it("should prioritize multi-message mode over waiting-for-human", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Set up waiting for human state
setWaitingForHuman(channelId);
assert.strictEqual(isWaitingForHuman(channelId), true);
// Enter multi-message mode (should take precedence in before-model-resolve)
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
assert.strictEqual(isWaitingForHuman(channelId), true); // Both states exist but multi-message mode takes priority in hook
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
assert.strictEqual(isWaitingForHuman(channelId), true); // Waiting for human state still exists
});
});
describe("shuffle mode with dormant state", () => {
it("should maintain shuffle setting when dormant", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Reset to dormant
resetTurn(channelId);
const dormantState = getTurnDebugInfo(channelId);
assert.strictEqual(dormantState.dormant, true);
assert.strictEqual(getChannelShuffling(channelId), true); // Shuffling setting should persist
// Reactivate
onNewMessage(channelId, "human-user", true);
const activeState = getTurnDebugInfo(channelId);
assert.strictEqual(activeState.dormant, false);
assert.strictEqual(getChannelShuffling(channelId), true); // Setting should still be there
});
});
describe("shuffle mode with mention override", () => {
it("should handle shuffle mode during mention override", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
// In real implementation, mention override would be set via setMentionOverride function
// This test ensures the settings coexist properly
const state = getTurnDebugInfo(channelId);
assert.ok(state.hasTurnState);
assert.strictEqual(getChannelShuffling(channelId), true);
});
});
describe("multi-message mode with dormant state", () => {
it("should exit multi-message mode properly from dormant state", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Reset to dormant
resetTurn(channelId);
const dormantState = getTurnDebugInfo(channelId);
assert.strictEqual(dormantState.dormant, true);
// Enter multi-message mode while dormant
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
// Should still be dormant
const stateAfterExit = getTurnDebugInfo(channelId);
assert.strictEqual(stateAfterExit.dormant, true);
});
});
describe("complete workflow with all modes", () => {
it("should handle transitions between all modes", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Start with shuffling enabled
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Enter multi-message mode
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
assert.strictEqual(getChannelShuffling(channelId), true);
// Set waiting for human
setWaitingForHuman(channelId);
assert.strictEqual(isWaitingForHuman(channelId), true);
assert.strictEqual(getChannelShuffling(channelId), true);
// Reactivate with human message
onNewMessage(channelId, "human-user", true);
const activeState = getTurnDebugInfo(channelId);
assert.strictEqual(activeState.dormant, false);
assert.strictEqual(isWaitingForHuman(channelId), false);
assert.strictEqual(getChannelShuffling(channelId), true);
// Test that agents can speak in normal mode with shuffling enabled
const turnResult = checkTurn(channelId, "agent-a");
// This would depend on current turn state, but the important thing is no errors occurred
assert.ok(typeof turnResult === "object");
});
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode } from "../plugin/core/channel-modes.ts";
import { initTurnOrder, checkTurn, getTurnDebugInfo, onNewMessage, resetTurn } from "../plugin/turn-manager.ts";
describe("Multi-Message Mode Tests", () => {
const channelId = "test-channel";
beforeEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId); // Ensure clean state
});
afterEach(() => {
resetTurn(channelId);
exitMultiMessageMode(channelId);
});
describe("multi-message mode state management", () => {
it("should enter multi-message mode", () => {
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
});
it("should exit multi-message mode", () => {
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
});
it("should start in normal mode by default", () => {
assert.strictEqual(isMultiMessageMode(channelId), false);
});
});
describe("compatibility with waiting-for-human", () => {
it("should properly handle multi-message mode with human messages", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enter multi-message mode
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
// Simulate human message in multi-message mode
onNewMessage(channelId, "human-user", true);
// Exit multi-message mode
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
// Should be able to proceed normally
onNewMessage(channelId, "human-user", true);
const turnResult = checkTurn(channelId, "agent-a");
assert.ok(turnResult);
});
});
describe("compatibility with mention override", () => {
it("should handle multi-message mode with mention override", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Enter multi-message mode
enterMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), true);
// Even with mention override conceptually, multi-message mode should take precedence
// In real usage, mention overrides happen in message-received hook before multi-message mode logic
const turnResult = checkTurn(channelId, "agent-a");
assert.ok(typeof turnResult === "object");
// The actual behavior depends on the before-model-resolve hook which forces no-reply in multi-message mode
// Exit multi-message mode to resume normal operation
exitMultiMessageMode(channelId);
assert.strictEqual(isMultiMessageMode(channelId), false);
});
});
describe("multi-message mode interaction with turn management", () => {
it("should pause turn management in multi-message mode", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Initially, turn should work normally
const normalTurnResult = checkTurn(channelId, "agent-a");
assert.ok(normalTurnResult);
// Enter multi-message mode
enterMultiMessageMode(channelId);
// In multi-message mode, agents should be blocked (this is handled in before-model-resolve hook)
// But the turn state itself continues to exist
const stateInMultiMessage = getTurnDebugInfo(channelId);
assert.ok(stateInMultiMessage.hasTurnState);
// Exit multi-message mode
exitMultiMessageMode(channelId);
const stateAfterExit = getTurnDebugInfo(channelId);
assert.ok(stateAfterExit.hasTurnState);
});
});
});

215
test/register-tools.test.ts Normal file
View File

@@ -0,0 +1,215 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerDirigentTools } from '../plugin/tools/register-tools.ts';
type RegisteredTool = {
name: string;
parameters?: Record<string, unknown>;
handler: (params: Record<string, unknown>, ctx?: Record<string, unknown>) => Promise<any>;
};
function makeApi() {
const tools = new Map<string, RegisteredTool>();
return {
config: {
channels: {
discord: {
accounts: {
bot: { token: 'discord-bot-token' },
},
},
},
},
logger: {
info: (_msg: string) => {},
warn: (_msg: string) => {},
},
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
tools,
};
}
function pickDefined(obj: Record<string, unknown>) {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
}
test('plain private channel create works unchanged without discussion params', async () => {
const api = makeApi();
let initDiscussionCalls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit) => {
return new Response(JSON.stringify({ id: 'created-channel-1', name: 'plain-room' }), { status: 200 });
}) as typeof fetch;
try {
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion() {
initDiscussionCalls += 1;
return {};
},
async handleCallback() {
return { ok: true };
},
},
});
const tool = api.tools.get('dirigent_discord_control');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({
action: 'channel-private-create',
guildId: 'guild-1',
name: 'plain-room',
allowedUserIds: ['user-1'],
}, {
agentId: 'agent-a',
sessionKey: 'session-a',
});
assert.equal(result.isError, undefined);
assert.match(result.content[0].text, /"discussionMode": false/);
assert.equal(initDiscussionCalls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test('private channel create rejects callbackChannelId without discussGuide', async () => {
const api = makeApi();
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion() {
return {};
},
async handleCallback() {
return { ok: true };
},
},
});
const tool = api.tools.get('dirigent_discord_control');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({
action: 'channel-private-create',
guildId: 'guild-1',
name: 'discussion-room',
callbackChannelId: 'origin-1',
}, {
agentId: 'agent-a',
sessionKey: 'session-a',
});
assert.equal(result.isError, true);
assert.equal(result.content[0].text, 'discussGuide is required when callbackChannelId is provided');
});
test('discussion-mode channel create initializes discussion metadata', async () => {
const api = makeApi();
const initCalls: Array<Record<string, unknown>> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit) => {
return new Response(JSON.stringify({ id: 'discussion-channel-1', name: 'discussion-room' }), { status: 200 });
}) as typeof fetch;
try {
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion(params) {
initCalls.push(params as Record<string, unknown>);
return {};
},
async handleCallback() {
return { ok: true };
},
},
});
const tool = api.tools.get('dirigent_discord_control');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({
action: 'channel-private-create',
guildId: 'guild-1',
name: 'discussion-room',
callbackChannelId: 'origin-1',
discussGuide: 'Decide the callback contract.',
}, {
agentId: 'agent-a',
sessionKey: 'session-a',
workspaceRoot: '/workspace/agent-a',
});
assert.equal(result.isError, undefined);
assert.match(result.content[0].text, /"discussionMode": true/);
assert.equal(initCalls.length, 1);
assert.deepEqual(initCalls[0], {
discussionChannelId: 'discussion-channel-1',
originChannelId: 'origin-1',
initiatorAgentId: 'agent-a',
initiatorSessionId: 'session-a',
initiatorWorkspaceRoot: '/workspace/agent-a',
discussGuide: 'Decide the callback contract.',
});
} finally {
globalThis.fetch = originalFetch;
}
});
test('discuss-callback registers and forwards channel/session/agent context', async () => {
const api = makeApi();
const callbackCalls: Array<Record<string, unknown>> = [];
registerDirigentTools({
api: api as any,
baseConfig: {},
pickDefined,
discussionService: {
async initDiscussion() {
return {};
},
async handleCallback(params) {
callbackCalls.push(params as Record<string, unknown>);
return { ok: true, summaryPath: '/workspace/summary.md' };
},
},
});
const tool = api.tools.get('discuss-callback');
assert.ok(tool);
assert.ok(tool!.parameters);
const result = await tool!.handler({ summaryPath: 'plans/summary.md' }, {
channelId: 'discussion-1',
agentId: 'agent-a',
sessionKey: 'session-a',
});
assert.equal(result.isError, undefined);
assert.deepEqual(callbackCalls, [{
channelId: 'discussion-1',
summaryPath: 'plans/summary.md',
callerAgentId: 'agent-a',
callerSessionKey: 'session-a',
}]);
assert.match(result.content[0].text, /"summaryPath": "\/workspace\/summary.md"/);
});

183
test/shuffle-mode.test.ts Normal file
View File

@@ -0,0 +1,183 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { initTurnOrder, checkTurn, onSpeakerDone, advanceTurn, resetTurn, getTurnDebugInfo, onNewMessage } from "../plugin/turn-manager.ts";
import { setChannelShuffling, getChannelShuffling } from "../plugin/core/channel-modes.ts";
describe("Shuffle Mode Tests", () => {
const channelId = "test-channel";
beforeEach(() => {
resetTurn(channelId);
});
afterEach(() => {
resetTurn(channelId);
});
describe("/turn-shuffling command functionality", () => {
it("should enable shuffle mode", () => {
setChannelShuffling(channelId, true);
assert.strictEqual(getChannelShuffling(channelId), true);
});
it("should disable shuffle mode", () => {
setChannelShuffling(channelId, false);
assert.strictEqual(getChannelShuffling(channelId), false);
});
it("should start with shuffle mode disabled by default", () => {
assert.strictEqual(getChannelShuffling(channelId), false);
});
});
describe("shuffle mode behavior", () => {
it("should not reshuffle when shuffling is disabled", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Disable shuffling (should be default anyway)
setChannelShuffling(channelId, false);
// Simulate a full cycle without reshuffling
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
const firstSpeaker = initialOrder[0];
// Have first speaker finish their turn
onSpeakerDone(channelId, firstSpeaker, false);
// Check that the order didn't change (since shuffling is disabled)
const orderAfterOneTurn = getTurnDebugInfo(channelId).turnOrder as string[];
// The order should remain the same when shuffling is disabled
assert.deepStrictEqual(initialOrder, orderAfterOneTurn);
});
it("should reshuffle when shuffling is enabled after a full cycle", () => {
const botIds = ["agent-a", "agent-b", "agent-c"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Get initial order
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
const firstSpeaker = initialOrder[0];
// Complete a full cycle by having each agent speak once
for (const agent of initialOrder) {
const turnResult = checkTurn(channelId, agent);
if (turnResult.allowed) {
onSpeakerDone(channelId, agent, false);
}
}
// After a full cycle, the order should have potentially changed if shuffling is enabled
const orderAfterCycle = getTurnDebugInfo(channelId).turnOrder as string[];
// The order might be different due to shuffling, or it might be the same by chance
// But the important thing is that the shuffling mechanism was called
assert(Array.isArray(orderAfterCycle));
assert.strictEqual(orderAfterCycle.length, 3);
});
it("should ensure last speaker doesn't become first in next round when shuffling", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Get initial order
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
assert.strictEqual(initialOrder.length, 2);
// Have first agent speak
const firstSpeaker = initialOrder[0];
const secondSpeaker = initialOrder[1];
// Have first speaker finish
onSpeakerDone(channelId, firstSpeaker, false);
// Have second speaker finish (completing a full cycle)
onSpeakerDone(channelId, secondSpeaker, false);
// The turn order should be reshuffled but with constraints
const orderAfterReshuffle = getTurnDebugInfo(channelId).turnOrder as string[];
// Verify the order is still valid
assert.strictEqual(orderAfterReshuffle.length, 2);
assert.ok(orderAfterReshuffle.includes("agent-a"));
assert.ok(orderAfterReshuffle.includes("agent-b"));
});
it("should handle single agent scenario gracefully", () => {
const botIds = ["agent-a"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Dormant channels need a new message to activate the first speaker.
onNewMessage(channelId, "human-user", true);
const turnResult = checkTurn(channelId, "agent-a");
assert.strictEqual(turnResult.allowed, true);
onSpeakerDone(channelId, "agent-a", false);
const stateAfter = getTurnDebugInfo(channelId);
assert.deepStrictEqual(stateAfter.turnOrder, ["agent-a"]);
assert.strictEqual(stateAfter.currentSpeaker, "agent-a");
});
it("should handle double agent scenario properly", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
// Enable shuffling
setChannelShuffling(channelId, true);
// Activate the channel before exercising the round transition.
onNewMessage(channelId, "human-user", true);
const initialOrder = getTurnDebugInfo(channelId).turnOrder as string[];
const firstSpeaker = initialOrder[0];
const secondSpeaker = initialOrder[1];
// Have first speaker finish
onSpeakerDone(channelId, firstSpeaker, false);
// Have second speaker finish (this completes a cycle)
onSpeakerDone(channelId, secondSpeaker, false);
// The order might be reshuffled, but it should be valid
const newState = getTurnDebugInfo(channelId);
const newOrder = newState.turnOrder as string[];
assert.strictEqual(newOrder.length, 2);
assert.ok(newOrder.includes("agent-a"));
assert.ok(newOrder.includes("agent-b"));
// After a full round, the next current speaker should already be set.
assert.ok(["agent-a", "agent-b"].includes(newState.currentSpeaker as string));
});
});
describe("compatibility with other modes", () => {
it("should work with dormant state", () => {
const botIds = ["agent-a", "agent-b"];
initTurnOrder(channelId, botIds);
setChannelShuffling(channelId, true);
// Start with dormant state
resetTurn(channelId);
const dormantState = getTurnDebugInfo(channelId);
assert.strictEqual(dormantState.dormant, true);
// Activate with new message
onNewMessage(channelId, "agent-a", false);
const activeState = getTurnDebugInfo(channelId);
assert.strictEqual(activeState.dormant, false);
assert.ok(activeState.currentSpeaker);
});
});
});