Compare commits

..

4 Commits

Author SHA1 Message Date
220ec85e6f Clean OpenClaw config on uninstall 2026-03-10 18:27:54 +00:00
00108c357b Add OpenClaw manifest and configure install 2026-03-10 18:25:54 +00:00
3b26f3d083 Merge pull request 'refactor: restructure project layout and add install.mjs' (#3) from feat/restructure-and-install-script into main
Reviewed-on: #3
2026-03-10 14:46:58 +00:00
zhi
a0e926594f refactor: restructure project layout and add install.mjs
- Move src/ → plugin/ with subdirectories:
  - plugin/core/ (business logic, models, store, permissions, utils, memory)
  - plugin/tools/ (query, resources)
  - plugin/commands/ (placeholder for slash commands)
  - plugin/hooks/ (placeholder for lifecycle hooks)
  - plugin/index.ts (wiring layer only, no business logic)
- Add install.mjs with --install, --uninstall, --openclaw-profile-path
- Add skills/ and docs/ root directories
- Move planning docs (PLAN.md, FEAT.md, AGENT_TASKS.md) to docs/
- Remove old scripts/install.sh
- Update tsconfig rootDir: src → plugin
- Update README.md and README.zh.md with new layout
- Bump version to 0.2.0
- All tests pass
2026-03-10 14:44:40 +00:00
45 changed files with 2365 additions and 3043 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.DS_Store
data/*.json
!data/.gitkeep

9
.gitmodules vendored
View File

@@ -1,9 +0,0 @@
[submodule "Yonexus.Server"]
path = Yonexus.Server
url = https://git.hangman-lab.top/nav/Yonexus.Server.git
[submodule "Yonexus.Client"]
path = Yonexus.Client
url = https://git.hangman-lab.top/nav/Yonexus.Client.git
[submodule "Yonexus.Protocol"]
path = Yonexus.Protocol
url = https://git.hangman-lab.top/nav/Yonexus.Protocol.git

View File

@@ -1,351 +0,0 @@
# Yonexus — Architecture Overview
## 1. Purpose
Yonexus is a cross-instance communication system for OpenClaw.
The repository `Yonexus` is the **umbrella/specification repository** for the system. It contains:
- high-level planning
- architecture documents
- references to implementation repositories as git submodules
Yonexus is implemented as three repositories:
- `Yonexus.Server` — central hub plugin
- `Yonexus.Client` — client plugin
- `Yonexus.Protocol` — shared protocol specification, referenced as a submodule by both
---
## 2. Repository Roles
## 2.1 `Yonexus` (umbrella repo)
Purpose:
- system-level planning
- architecture documents
- cross-cutting decisions that apply to both server and client
- coordination of sub-repositories via git submodules
This repository should contain:
- top-level planning docs
- architecture overview
- feature checklists
- cross-cutting design rationale
It references:
- `Yonexus.Server` (submodule)
- `Yonexus.Client` (submodule)
- `Yonexus.Protocol` (submodule)
## 2.2 `Yonexus.Protocol`
Purpose:
- protocol specification (PROTOCOL.md)
- canonical JSON shape references
- shared type definitions (planned)
Referenced as a submodule by:
- `Yonexus.Server/protocol`
- `Yonexus.Client/protocol`
This is the **single source of truth** for the Yonexus protocol. Both server and client implementations must conform to the protocol defined here.
## 2.3 `Yonexus.Server`
Purpose:
- implementation of the central hub/server plugin
- server-side connection management
- server-side pairing/authentication/state tracking
- server-side dispatch and routing behavior
Contains:
- `protocol/` — submodule pointing to `Yonexus.Protocol`
- `PLAN.md`
- implementation code
## 2.4 `Yonexus.Client`
Purpose:
- implementation of the client plugin
- outbound connection to `Yonexus.Server`
- local identity/keypair/secret management
- client-side pairing confirmation and authenticated reconnect
- client-side heartbeat and message sending
Contains:
- `protocol/` — submodule pointing to `Yonexus.Protocol`
- `PLAN.md`
- implementation code
---
## 3. Repository Graph
```
Yonexus (umbrella)
├── Yonexus.Protocol (submodule)
├── Yonexus.Server (submodule)
│ └── protocol/ (nested submodule -> Yonexus.Protocol)
└── Yonexus.Client (submodule)
└── protocol/ (nested submodule -> Yonexus.Protocol)
```
Policy:
- protocol changes are always committed to `Yonexus.Protocol` first
- `Yonexus.Server` and `Yonexus.Client` update their `protocol/` submodule ref after protocol version is stable
- umbrella `Yonexus` updates its submodule refs after server/client have stable versions
---
## 4. System Topology
A Yonexus deployment contains:
- exactly one `Yonexus.Server` instance
- one or more `Yonexus.Client` instances
Topology assumptions:
- `Yonexus.Server` runs on an OpenClaw instance reachable at a stable address
- each `Yonexus.Client` connects outbound to the server
- clients do not directly connect to each other in v1
- cross-client coordination is relayed through the server
Visual model:
```
Yonexus.Client A --->
\
Yonexus.Client B ----> Yonexus.Server
/
Yonexus.Client C --->
```
---
## 5. Shared vs Split Responsibilities
## 5.1 Yonexus.Protocol — Shared
These belong to the protocol repo and apply to both plugins:
- protocol format and message categories
- builtin message types and their semantics
- pairing security model
- nonce/timestamp validation rules
- heartbeat timing rules
- message rewrite rules
- reserved rule namespace (`builtin`)
- canonical JSON shapes
- naming and terminology
## 5.2 Server-Only Concerns (Yonexus.Server)
These belong in `Yonexus.Server`:
- WebSocket server startup
- listen host/port config
- client registry persistence
- public key / secret storage
- pairing code generation
- Discord DM notification to admin
- auth proof verification
- liveness status tracking
- client message rewriting and dispatch on server side
- sending messages to connected clients
## 5.3 Client-Only Concerns (Yonexus.Client)
These belong in `Yonexus.Client`:
- WebSocket client connection management
- reconnect/backoff logic
- local keypair generation
- local secret persistence
- pairing code submission
- auth proof construction/signing
- heartbeat sending
- sending messages to server
- receiving server messages and local dispatch
---
## 6. Communication Model
## 6.1 Transport
Transport is WebSocket.
- `Yonexus.Server` acts as server
- `Yonexus.Client` acts as client
## 6.2 Message Categories
Two message categories exist on the same transport:
### Builtin protocol messages
Used for:
- hello/session setup
- pairing
- authentication
- heartbeat
- lifecycle/status
- protocol errors
Format:
```text
builtin::{json}
```
### Application rule messages
Used for higher-level cross-instance communication.
Format:
```text
${rule_identifier}::${message_content}
```
Server rewrite rule:
When server receives a message from a client, before dispatch it rewrites:
```text
${rule_identifier}::${sender_identifier}::${message_content}
```
---
## 7. Security Architecture
## 7.1 Pairing Model
Pairing is intentionally out-of-band.
When a new client needs pairing:
- server generates a pairing code
- server sends that code to a human administrator via Discord DM
- server does **not** send the code over the Yonexus WebSocket channel
- human relays the code to the client side manually
- client submits the code back to the server
This preserves a basic human-mediated trust step.
## 7.2 Post-Pairing Authentication
After pairing:
- server issues a shared secret
- client stores secret locally
- client already has a private key
- reconnect auth uses signed proof derived from:
- secret
- nonce
- timestamp
## 7.3 Replay Protection
Server enforces:
- timestamp freshness (`< 10s` drift)
- nonce collision detection
- handshake rate threshold (`>10 attempts in 10s` is unsafe)
- re-pair requirement after unsafe conditions
---
## 8. State Ownership
## 8.1 Server-Owned State
Canonical server-owned state includes:
- allowed client identifiers
- trust state for each client
- client public key
- client secret
- pairing state
- pairing notification state
- recent nonce window
- recent handshake attempt window
- client liveness state
## 8.2 Client-Owned State
Canonical client-owned state includes:
- client identifier
- client private key
- client public key
- current shared secret
- last successful local trust metadata if needed
---
## 9. Plugin API Boundaries
## 9.1 Yonexus.Server API
Planned public API:
- `sendMessageToClient(identifier, message)`
- `registerRule(rule, processor)`
## 9.2 Yonexus.Client API
Planned public API:
- `sendMessageToServer(message)`
- `registerRule(rule, processor)`
The protocol defines semantics; implementation details belong in each submodule.
---
## 10. Documentation Ownership
## 10.1 Umbrella Repo Docs
Should contain:
- system architecture
- cross-cutting feature list
- global design rationale
- cross-repo coordination notes
## 10.2 Protocol Repo Docs
Must contain:
- protocol specification (PROTOCOL.md)
- canonical message shapes
- protocol versioning notes
## 10.3 Server Repo Docs
Should contain:
- server setup
- server config reference
- server persistence model
- server operational behavior
- implementation tasks
## 10.4 Client Repo Docs
Should contain:
- client setup
- client config reference
- client local storage model
- client reconnect/heartbeat behavior
- implementation tasks
---
## 11. Development Flow
Recommended flow:
1. define cross-cutting behavior in `Yonexus` umbrella
2. finalize protocol in `Yonexus.Protocol`
3. update submodule refs in `Yonexus.Server` and `Yonexus.Client`
4. implement server-side protocol handling in `Yonexus.Server`
5. implement client-side protocol handling in `Yonexus.Client`
6. keep protocol changes synchronized back into umbrella docs
---
## 12. Non-Goals of the Umbrella Repo
The umbrella repo should avoid becoming:
- the place where all implementation code lives
- a dumping ground for server-only or client-only details
- a duplicate of submodule READMEs without system-level value
Its job is coordination, not code concentration.

268
FEAT.md
View File

@@ -1,268 +0,0 @@
# Yonexus — Feature Checklist
## Project Direction
Yonexus is a **two-plugin** cross-instance communication system for OpenClaw:
- `Yonexus.Server`
- `Yonexus.Client`
This repository now targets the split-plugin architecture only.
---
## 1. Yonexus.Server Features
### 1.1 Server Runtime
- WebSocket server startup on OpenClaw gateway boot
- Configurable bind host / bind port
- Optional public WebSocket URL metadata
- Connection accept / close lifecycle handling
- One active authenticated connection per client identifier
### 1.2 Client Registry
- In-memory active client session registry
- Persistent client trust registry keyed by `identifier`
- Store client public key
- Store shared secret
- Store pairing state and expiry
- Store pairing notification metadata
- Store heartbeat timestamps
- Store recent security windows (nonce / handshake attempts)
- Store liveness state (`online | unstable | offline`)
### 1.3 Allowlist and Validation
- `followerIdentifiers` allowlist enforcement
- Reject unknown client identifiers
- Reject malformed builtin payloads
- Reject unsupported protocol versions
### 1.4 Pairing Flow
- Generate pairing code
- Generate pairing expiry / TTL
- Start pending pairing session
- Never send pairing code over Yonexus WebSocket
- Send pairing code to human admin via Discord DM using `notifyBotToken`
- Include target client `identifier` in pairing DM
- Accept client-submitted pairing code via builtin protocol
- Fail pairing on invalid code / expired code / notification failure
- Issue shared secret after successful pairing
- Persist paired trust material
### 1.5 Authentication
- Verify signed proof from client
- Validate stored secret
- Validate nonce format and uniqueness
- Validate timestamp drift `< 10s`
- Track recent handshake attempts
- Enforce `>10 attempts / 10s` unsafe threshold
- Trigger re-pair on unsafe condition
- Rotate or invalidate trust state when required
### 1.6 Heartbeat and Status
- Receive heartbeat from authenticated clients
- Update `lastHeartbeatAt`
- Mark client `unstable` after 7 minutes without heartbeat
- Mark client `offline` after 11 minutes without heartbeat
- Close socket when client becomes offline
- Optional heartbeat acknowledgement
- Periodic server-side status sweep timer
### 1.7 Messaging and Dispatch
- `sendMessageToClient(identifier, message)` API
- Rewrite inbound client messages to `${rule_identifier}::${sender_identifier}::${message_content}`
- Builtin message routing
- Rule registry for application messages
- First-match rule dispatch
- Reject reserved rule `builtin`
- Reject duplicate rule registration by default
### 1.8 Operations and Safety
- Structured errors for pairing/auth/transport failures
- Redacted logging for sensitive values
- Restart-safe persistent storage for trust state
- Clear or safely rebuild rolling security windows on restart
---
## 2. Yonexus.Client Features
### 2.1 Client Runtime
- WebSocket client startup on OpenClaw gateway boot
- Connect to configured `mainHost`
- Disconnect / reconnect lifecycle handling
- Retry/backoff reconnect strategy
### 2.2 Local Identity and Trust Material
- Persist local `identifier`
- Generate public/private keypair on first run
- Persist private key locally
- Persist server-issued secret locally
- Load existing trust material on restart
### 2.3 Pairing Flow
- Send `hello` after connect
- Enter pairing mode when server requires pairing
- Receive pairing metadata without receiving code itself
- Accept human-provided pairing code on client side
- Send pairing confirmation to server
- Store secret after `pair_success`
### 2.4 Authentication
- Build proof from `secret + nonce + timestamp`
- Prefer canonical serialized payload for signing
- Sign proof with local private key
- Send builtin `auth_request`
- Handle `auth_success`
- Handle `auth_failed`
- Handle `re_pair_required`
### 2.5 Heartbeat
- Start heartbeat loop after authentication
- Send heartbeat every 5 minutes
- Stop heartbeat when disconnected / unauthenticated
- Handle optional heartbeat acknowledgement
### 2.6 Messaging and Dispatch
- `sendMessageToServer(message)` API
- Builtin message routing
- Rule registry for application messages
- First-match rule dispatch
- Reject reserved rule `builtin`
- Reject duplicate rule registration by default
---
## 3. Shared Protocol Features
### 3.1 Builtin Wire Format
- `builtin::{json}` message format
- Standard builtin envelope with `type`, `requestId`, `timestamp`, `payload`
- UTC unix seconds as protocol timestamp unit
### 3.2 Builtin Types
- `hello`
- `hello_ack`
- `pair_request`
- `pair_confirm`
- `pair_success`
- `pair_failed`
- `auth_request`
- `auth_success`
- `auth_failed`
- `re_pair_required`
- `heartbeat`
- `heartbeat_ack`
- `status_update`
- `disconnect_notice`
- `error`
### 3.3 Security Constraints
- Pairing code must be delivered out-of-band only
- Pairing code must not travel over Yonexus WebSocket
- Nonce length fixed at 24 random characters
- Nonce replay detection window
- Timestamp freshness validation
- Rate-limit / unsafe reconnect detection
### 3.4 Rule Message Format
- Application messages use `${rule_identifier}::${message_content}`
- Server rewrites inbound client messages before dispatch
- Rule matching is exact-match in v1
---
## 4. Configuration Features
### 4.1 Yonexus.Server Config
- `followerIdentifiers: string[]`
- `notifyBotToken: string`
- `adminUserId: string`
- `listenHost?: string`
- `listenPort: number`
- `publicWsUrl?: string`
### 4.2 Yonexus.Client Config
- `mainHost: string`
- `identifier: string`
- `notifyBotToken: string`
- `adminUserId: string`
### 4.3 Validation
- Fail startup on missing required fields
- Fail startup on invalid config shape
- Validate required split-plugin semantics per side
---
## 5. Docs and Deliverables
### Required Planning / Spec Docs
- `PLAN.md`
- `PROTOCOL.md`
- `FEAT.md`
### Next Implementation Deliverables
- server plugin manifest
- client plugin manifest
- README for dual-plugin architecture
- implementation task breakdown
- protocol test cases
- pairing/auth failure-path test matrix
---
## 6. Suggested Delivery Order
### Phase 0 — Planning
- [x] Rewrite project direction
- [x] Define split-plugin model
- [x] Write protocol draft
- [x] Write feature checklist
### Phase 1 — Skeleton
- [ ] Create `Yonexus.Server` plugin scaffold
- [ ] Create `Yonexus.Client` plugin scaffold
- [ ] Add config schema / manifests
- [ ] Add minimal startup hooks
### Phase 2 — Transport
- [ ] Implement WebSocket server
- [ ] Implement WebSocket client
- [ ] Implement hello / hello_ack flow
- [ ] Implement reconnect baseline
### Phase 3 — Pairing and Auth
- [ ] Implement keypair generation
- [ ] Implement pairing creation
- [ ] Implement Discord DM notification
- [ ] Implement pairing confirmation
- [ ] Implement secret issuance
- [ ] Implement signed auth proof validation
- [ ] Implement nonce and rate-limit protection
### Phase 4 — Heartbeat and Messaging
- [ ] Implement heartbeat loop
- [ ] Implement server status sweep
- [ ] Implement `sendMessageToServer`
- [ ] Implement `sendMessageToClient`
- [ ] Implement rule registry and dispatch
### Phase 5 — Hardening
- [ ] Add persistence
- [ ] Add restart recovery behavior
- [ ] Add structured errors
- [ ] Add logging/redaction
- [ ] Add integration tests
- [ ] Add operator docs
---
## 7. Non-Goals
Not in initial scope unless explicitly added later:
- direct client-to-client sockets
- multi-server topology
- distributed consensus
- queueing guarantees for offline clients
- management UI
- advanced pattern matching for rules

579
PLAN.md
View File

@@ -1,579 +0,0 @@
# Yonexus — Project Plan
## 1. Goal
Yonexus is a cross-instance communication system for OpenClaw, implemented as **two separate plugins**:
- `Yonexus.Server`
- `Yonexus.Client`
Together they provide:
- communication between multiple OpenClaw instances
- a central WebSocket hub model
- client pairing and authentication
- heartbeat-based client liveness tracking
- rule-based message dispatch
- out-of-band pairing notification to a human administrator via Discord DM
- TypeScript interfaces for higher-level plugin/runtime integrations
This project is no longer a role-switched single plugin. It is now explicitly split into two installable plugins with distinct responsibilities.
---
## 2. Plugin Split
## 2.1 Yonexus.Server
`Yonexus.Server` is installed only on the main OpenClaw instance.
Responsibilities:
- start and maintain the WebSocket server
- accept incoming client connections
- maintain the client registry
- handle pairing flow
- verify authentication proofs
- track heartbeat and connection state
- route or relay messages to connected clients
- rewrite inbound client messages before rule dispatch
- send Discord DM pairing notifications to the human administrator
## 2.2 Yonexus.Client
`Yonexus.Client` is installed on follower OpenClaw instances.
Responsibilities:
- connect to the configured Yonexus server
- generate and persist local keypair on first use
- persist local client identity and secret
- perform pairing confirmation
- perform authenticated reconnect
- send periodic heartbeats
- expose client-side messaging and rule registration APIs
---
## 3. Deployment Model
A Yonexus network contains:
- exactly one OpenClaw instance running `Yonexus.Server`
- one or more OpenClaw instances running `Yonexus.Client`
Topology rules:
- `Yonexus.Server` must be reachable via fixed IP/domain or otherwise stable addressable endpoint
- `Yonexus.Client` instances do not need stable public IP/domain
- all `Yonexus.Client` instances connect outbound to the `Yonexus.Server` WebSocket endpoint
- no direct client-to-client communication is required in v1
- inter-client communication, if needed, is relayed by `Yonexus.Server`
---
## 4. Configuration Model
## 4.1 Yonexus.Server Config
```ts
followerIdentifiers: string[]
notifyBotToken: string
adminUserId: string
listenHost?: string
listenPort: number
publicWsUrl?: string
```
Semantics:
- `followerIdentifiers`: allowlist of client identifiers permitted to pair/connect
- `notifyBotToken`: Discord bot token used to send pairing notifications
- `adminUserId`: Discord user id of the human administrator who receives pairing codes by DM
- `listenHost`: local bind host for WebSocket server
- `listenPort`: local bind port for WebSocket server
- `publicWsUrl`: optional canonical external URL advertised/documented for clients
## 4.2 Yonexus.Client Config
```ts
mainHost: string
identifier: string
notifyBotToken: string
adminUserId: string
```
Semantics:
- `mainHost`: WebSocket endpoint of `Yonexus.Server`
- `identifier`: unique identity of this client inside the Yonexus network
- `notifyBotToken`: kept aligned with shared config expectations if future client-side notification behaviors are needed
- `adminUserId`: human administrator identity reference shared with the Yonexus system
## 4.3 Validation Rules
### Yonexus.Server
- must provide `followerIdentifiers`
- must provide `notifyBotToken`
- must provide `adminUserId`
- must provide `listenPort`
- must be deployed on a reachable/stable endpoint
### Yonexus.Client
- must provide `mainHost`
- must provide `identifier`
- must provide `notifyBotToken`
- must provide `adminUserId`
### Shared
- invalid or missing required fields must fail plugin initialization
- unknown client identifiers must be rejected by `Yonexus.Server`
---
## 5. Runtime Lifecycle
## 5.1 Yonexus.Server Startup
On OpenClaw gateway startup:
- initialize persistent client registry
- start WebSocket server
- register builtin protocol handlers
- register application rule registry
- start heartbeat/status sweep timer
## 5.2 Yonexus.Client Startup
On OpenClaw gateway startup:
- load local persisted identity, private key, and secret state
- generate keypair if absent
- connect to `mainHost`
- perform pairing or authentication flow depending on local state
- start heartbeat schedule after successful authentication
- attempt reconnect when disconnected
---
## 6. Server Registry and Persistence
`Yonexus.Server` must maintain a registry keyed by client `identifier`.
Each client record contains at minimum:
- `identifier`
- `publicKey`
- `secret`
- pairing state
- pairing expiration data
- pairing notification metadata
- connection status
- security counters/window data
- heartbeat timestamps
- last known session metadata
The registry must use:
- in-memory runtime state for active connections and recent security windows
- persistent on-disk storage for durable trust state
### 6.1 Proposed Server Record Shape
```ts
interface ClientRecord {
identifier: string;
publicKey?: string;
secret?: string;
pairingStatus: "unpaired" | "pending" | "paired" | "revoked";
pairingCode?: string;
pairingExpiresAt?: number;
pairingNotifiedAt?: number;
pairingNotifyStatus?: "pending" | "sent" | "failed";
status: "online" | "offline" | "unstable";
lastHeartbeatAt?: number;
lastAuthenticatedAt?: number;
recentNonces: Array<{
nonce: string;
timestamp: number;
}>;
recentHandshakeAttempts: number[];
createdAt: number;
updatedAt: number;
}
```
---
## 7. Pairing and Authentication
## 7.1 First Connection and Key Generation
When a client connects to the server for the first time:
- `Yonexus.Client` generates a public/private key pair locally
- the private key remains only on the client instance
- the public key is sent to `Yonexus.Server` during handshake
If the server sees that:
- the client identifier is allowed, and
- there is no valid `secret` currently associated with that identifier
then the server must enter pairing flow.
## 7.2 Pairing Flow
### Step A: Pairing Request Creation
`Yonexus.Server` generates:
- a random pairing string
- an expiration time
The pairing string must **not** be sent to the client over WebSocket.
Instead, `Yonexus.Server` uses `notifyBotToken` to send a Discord DM to `adminUserId` containing:
- the client `identifier`
- the generated `pairingCode`
- the expiration time
### Step B: Pairing Confirmation
The client must provide the pairing code back to the server before expiration.
How the client operator obtains the pairing code is intentionally out-of-band from the Yonexus WebSocket channel. The server only trusts that the code came through some human-mediated path.
If the client sends the correct pairing code before expiration:
- pairing succeeds
### Step C: Secret Issuance
After successful pairing:
- `Yonexus.Server` generates a random `secret`
- `Yonexus.Server` returns that `secret` to the client
- `Yonexus.Server` stores client `publicKey` + `secret`
- `Yonexus.Client` stores private key + secret locally
If Discord DM delivery fails:
- pairing must not proceed
- server should mark the pairing attempt as failed or pending-error
- client must not receive a usable pairing code through the protocol channel
If pairing expires before confirmation:
- pairing fails
- the client must restart the pairing process
## 7.3 Reconnect Authentication Flow
After pairing is complete, future client authentication must use:
- the stored `secret`
- a 24-character random nonce
- current UTC Unix timestamp
The client builds a proof payload from:
- `secret`
- `nonce`
- `timestamp`
Logical concatenation order:
```text
secret + nonce + timestamp
```
Implementation recommendation:
- use a canonical serialized object and sign its bytes rather than naive string concatenation in code
The client signs the proof using its private key and sends it to the server.
The server verifies:
1. identifier is known and paired
2. public key matches stored state
3. proof contains the correct `secret`
4. timestamp difference from current time is less than 10 seconds
5. nonce does not collide with the recent nonce window
6. handshake attempts in the last 10 seconds do not exceed 10
If all checks pass:
- authentication succeeds
- the client is considered authenticated for the session
If any check fails:
- authentication fails
- server may downgrade or revoke trust state
## 7.4 Unsafe Condition Handling
The connection is considered unsafe and must return to pairing flow if either is true:
- more than 10 handshake attempts occur within 10 seconds
- the presented nonce collides with one of the last 10 nonces observed within the recent window
When unsafe:
- existing trust state must no longer be accepted for authentication
- the client must re-pair
- server should clear or rotate the stored `secret`
- server should reset security windows as part of re-pairing
---
## 8. Heartbeat and Client Status
The server must track each clients liveness state:
- `online`
- `unstable`
- `offline`
## 8.1 Heartbeat Rules
Each client must send a heartbeat to the server every 5 minutes.
## 8.2 Status Transitions
### online
A client is `online` when:
- it has an active authenticated WebSocket connection, and
- the server has received a recent heartbeat
### unstable
A client becomes `unstable` when:
- no heartbeat has been received for 7 minutes
### offline
A client becomes `offline` when:
- no heartbeat has been received for 11 minutes
When a client becomes `offline`:
- the server must close/terminate the WebSocket connection for that client
## 8.3 Status Evaluation Strategy
The server should run a periodic status sweep timer.
Recommended interval:
- every 30 to 60 seconds
---
## 9. Messaging Model
Yonexus provides rule-based message dispatch over WebSocket.
## 9.1 Base Message Format
All application messages must use the format:
```text
${rule_identifier}::${message_content}
```
## 9.2 Server-Side Rewriting
When `Yonexus.Server` receives a message from a client, before rule matching it must rewrite the message into:
```text
${rule_identifier}::${sender_identifier}::${message_content}
```
This ensures server-side processors can identify which client sent the message.
## 9.3 Builtin Rule Namespace
The reserved rule identifier is:
```text
builtin
```
It is used internally for:
- handshake
- pairing
- heartbeat
- protocol/system messages
User code must not be allowed to register handlers for `builtin`.
---
## 10. TypeScript API Surface
## 10.1 Yonexus.Client API
```ts
sendMessageToServer(message: string): Promise<void>
```
Rules:
- sends message to connected `Yonexus.Server`
- message must already conform to `${rule_identifier}::${message_content}`
```ts
registerRule(rule: string, processor: (message: string) => unknown): void
```
Rules:
- rejects `builtin`
- rejects duplicate rule registration unless explicit override support is added later
## 10.2 Yonexus.Server API
```ts
sendMessageToClient(identifier: string, message: string): Promise<void>
```
Rules:
- target client must be known and currently connected/authenticated
- message must already conform to `${rule_identifier}::${message_content}`
```ts
registerRule(rule: string, processor: (message: string) => unknown): void
```
Rules:
- rejects `builtin`
- rejects duplicate rule registration unless explicit override support is added later
- processors are invoked with the final received string after any server-side rewrite
---
## 11. Hooks and Integration
## 11.1 Yonexus.Server Hooking
`Yonexus.Server` must register hooks so that when OpenClaw gateway starts:
- the WebSocket server is started
- the server registry is initialized
- builtin protocol handling is enabled
- heartbeat sweep begins
## 11.2 Yonexus.Client Behavior
`Yonexus.Client` must:
- connect outbound to `mainHost`
- manage local trust material
- handle pairing/authentication transitions
- emit heartbeats after authentication
- reconnect after disconnect with retry/backoff behavior
---
## 12. Storage Strategy
## 12.1 Yonexus.Server Storage
Server persists at minimum:
- identifier
- public key
- secret
- trust state
- pairing code + expiry if pairing is pending
- pairing notification metadata
- last known status
- metadata timestamps
May persist or reset on restart:
- recent nonces
- recent handshake attempts
Recommended v1:
- clear rolling security windows on restart
- keep long-lived trust records
## 12.2 Yonexus.Client Storage
Client persists at minimum:
- identifier
- private key
- secret
- optional last successful pair/auth metadata
Security notes:
- private key must never be sent to the server
- secret must be treated as sensitive material
- encryption-at-rest can be a future enhancement, but any plaintext local storage must be documented as a limitation if used initially
---
## 13. Error Handling
Structured errors should exist for at least:
- invalid configuration
- unauthorized identifier
- pairing required
- pairing expired
- pairing notification failure
- handshake verification failure
- replay/nonce collision detected
- unsafe handshake rate detected
- target client not connected
- duplicate rule registration
- reserved rule registration
- malformed message
---
## 14. Initial Implementation Phases
## Phase 0 — Protocol and Skeleton
- finalize split-plugin configuration schema
- define persistent data models
- define builtin protocol messages
- define startup hooks for both plugins
- define rule registry behavior
- define Discord DM notification flow
## Phase 1 — Transport MVP
- Yonexus.Server WebSocket server startup
- Yonexus.Client WebSocket client startup
- reconnect logic
- builtin protocol channel
- persistent registry/state scaffolding
## Phase 2 — Pairing and Authentication
- client keypair generation
- pairing request creation
- Discord DM notification to admin user
- pairing confirmation flow
- secret issuance and persistence
- signed proof verification
- nonce/replay protection
- unsafe-condition reset to pairing
## Phase 3 — Heartbeat and Status Tracking
- client heartbeat sender
- server heartbeat receiver
- periodic sweep
- status transitions: online / unstable / offline
- forced disconnect on offline
## Phase 4 — Public APIs and Dispatch
- `sendMessageToServer`
- `sendMessageToClient`
- `registerRule`
- first-match dispatch
- server-side sender rewrite behavior
## Phase 5 — Hardening and Docs
- integration tests
- failure-path coverage
- restart recovery checks
- protocol docs
- operator setup docs for server/client deployment
---
## 15. Non-Goals for Initial Version
Not required in the first version unless explicitly added later:
- direct client-to-client sockets
- multi-server clustering
- distributed consensus
- message ordering guarantees across reconnects
- end-to-end payload encryption beyond the pairing/authentication requirements
- management UI
---
## 16. Open Questions To Confirm Later
1. Exact signing algorithm:
- Ed25519 is a strong default candidate
2. Should `mainHost` accept only full WebSocket URLs or also raw `ip:port` strings?
3. Is human code relay sufficient for v1 pairing, or should admin approve/deny controls be added later?
4. On unsafe condition, should the old public key be retained or should the client generate a new keypair?
5. Should offline clients support queued outbound messages from server, or should sends fail immediately?
6. Are rule identifiers exact strings only, or should regex/prefix matching exist later?
---
## 17. Immediate Next Deliverables
After this plan, the next files to create should be:
- `FEAT.md` — feature checklist derived from this plan
- `README.md` — concise system overview for both plugins
- `plugin.server.json` or equivalent server plugin manifest
- `plugin.client.json` or equivalent client plugin manifest
- implementation task breakdown

View File

@@ -1,845 +0,0 @@
# Yonexus Protocol Specification
Version: draft v0.3
Status: planning
---
## 1. Purpose
This document defines the built-in Yonexus communication protocol used between:
- `Yonexus.Server`
- `Yonexus.Client`
The protocol covers:
- connection setup
- pairing
- authentication
- heartbeat
- status/lifecycle events
- protocol-level errors
- transport of application rule messages over the same WebSocket channel
Important security rule:
- pairing codes must **not** be delivered to `Yonexus.Client` over the Yonexus WebSocket channel
- pairing codes must be delivered out-of-band to a human administrator via Discord DM
---
## 2. Transport
Transport is WebSocket.
- `Yonexus.Server` listens as WebSocket server
- `Yonexus.Client` connects as WebSocket client
- protocol frames are UTF-8 text in v1
- binary frames are not required in v1
Client connects to configured `mainHost`, which may be:
- `ws://host:port/path`
- `wss://host:port/path`
- or raw `host:port` if normalized by implementation
Recommended canonical config:
- prefer full WebSocket URL
---
## 3. Message Categories
## 3.1 Builtin Protocol Messages
Builtin messages always use:
```text
builtin::${json_payload}
```
`builtin` is reserved and must not be registered by user code.
## 3.2 Application Rule Messages
Application messages use:
```text
${rule_identifier}::${message_content}
```
When `Yonexus.Server` receives a rule message from a client, it must rewrite it before dispatch to:
```text
${rule_identifier}::${sender_identifier}::${message_content}
```
---
## 4. Builtin Envelope
Builtin wire format:
```text
builtin::{JSON}
```
Canonical envelope:
```ts
interface BuiltinEnvelope {
type: string;
requestId?: string;
timestamp?: number; // UTC unix seconds
payload?: Record<string, unknown>;
}
```
Rules:
- `timestamp` uses UTC unix seconds
- `requestId` is used for correlation where needed
- `payload` content depends on `type`
---
## 5. Builtin Message Types
## 5.1 Session Setup
### `hello`
Sent by `Yonexus.Client` immediately after WebSocket connection opens.
Purpose:
- declare identifier
- advertise current auth material state
- announce protocol version
Example:
```text
builtin::{
"type":"hello",
"requestId":"req_001",
"timestamp":1711886400,
"payload":{
"identifier":"client-a",
"hasSecret":true,
"hasKeyPair":true,
"publicKey":"<optional-public-key>",
"protocolVersion":"1"
}
}
```
### `hello_ack`
Sent by `Yonexus.Server` in response to `hello`.
Possible `nextAction` values:
- `pair_required`
- `auth_required`
- `rejected`
- `waiting_pair_confirm`
Example:
```text
builtin::{
"type":"hello_ack",
"requestId":"req_001",
"timestamp":1711886401,
"payload":{
"identifier":"client-a",
"nextAction":"pair_required"
}
}
```
---
## 5.2 Pairing Flow
## 5.2.1 Pairing Design Rule
`Yonexus.Server` must never send the actual `pairingCode` to `Yonexus.Client` through the Yonexus WebSocket channel.
The pairing code must be delivered to the configured human administrator using:
- `notifyBotToken`
- `adminUserId`
Specifically:
- `Yonexus.Server` sends a Discord DM to the configured admin user
- the DM contains the client identifier and pairing code
- the human relays the code to the client side by some trusted out-of-band path
## 5.2.2 Pairing Request Creation
When pairing is required, `Yonexus.Server` generates:
- `pairingCode`
- `expiresAt`
- `ttlSeconds`
The admin DM must include at minimum:
- `identifier`
- `pairingCode`
- `expiresAt` or TTL
Example DM body:
```text
Yonexus pairing request
identifier: client-a
pairingCode: ABCD-1234-XYZ
expiresAt: 1711886702
```
### `pair_request`
Sent by `Yonexus.Server` to `Yonexus.Client` after pairing starts.
Purpose:
- indicate that pairing has started
- indicate whether admin notification succeeded
- provide expiry metadata without revealing the code
Example:
```text
builtin::{
"type":"pair_request",
"requestId":"req_002",
"timestamp":1711886402,
"payload":{
"identifier":"client-a",
"expiresAt":1711886702,
"ttlSeconds":300,
"adminNotification":"sent",
"codeDelivery":"out_of_band"
}
}
```
Allowed `adminNotification` values:
- `sent`
- `failed`
If notification failed, pairing must not proceed until retried successfully.
### `pair_confirm`
Sent by `Yonexus.Client` to confirm pairing.
Purpose:
- submit the pairing code obtained out-of-band
Example:
```text
builtin::{
"type":"pair_confirm",
"requestId":"req_002",
"timestamp":1711886410,
"payload":{
"identifier":"client-a",
"pairingCode":"ABCD-1234-XYZ"
}
}
```
### `pair_success`
Sent by `Yonexus.Server` after successful pairing.
Purpose:
- return generated secret
- confirm trusted pairing state
Example:
```text
builtin::{
"type":"pair_success",
"requestId":"req_002",
"timestamp":1711886411,
"payload":{
"identifier":"client-a",
"secret":"<random-secret>",
"pairedAt":1711886411
}
}
```
### `pair_failed`
Sent by `Yonexus.Server` when pairing fails.
Typical reasons:
- `expired`
- `invalid_code`
- `identifier_not_allowed`
- `admin_notification_failed`
- `internal_error`
Example:
```text
builtin::{
"type":"pair_failed",
"requestId":"req_002",
"timestamp":1711886710,
"payload":{
"identifier":"client-a",
"reason":"expired"
}
}
```
---
## 5.3 Authentication Flow
After pairing, reconnect authentication uses:
- stored `secret`
- 24-character random nonce
- current UTC unix timestamp
- client private key
## 5.3.1 Proof Construction
Logical proof content:
```text
secret + nonce + timestamp
```
Implementation recommendation:
- use canonical serialized object bytes for signing
Recommended logical form:
```json
{
"secret":"...",
"nonce":"...",
"timestamp":1711886500
}
```
## 5.3.2 Signature Primitive
Recommended primitive:
- digital signature using client private key
- verification using stored client public key on server
### `auth_request`
Sent by `Yonexus.Client` after pairing or on reconnect.
Example:
```text
builtin::{
"type":"auth_request",
"requestId":"req_003",
"timestamp":1711886500,
"payload":{
"identifier":"client-a",
"nonce":"RANDOM24CHARACTERSTRINGX",
"proofTimestamp":1711886500,
"signature":"<base64-signature>",
"publicKey":"<optional-public-key-if-rotating>"
}
}
```
Server validation:
1. identifier is allowlisted
2. identifier exists in registry
3. client is in paired state
4. public key matches expected key if provided
5. signature verifies successfully
6. proof contains correct secret
7. `abs(now - proofTimestamp) < 10`
8. nonce has not appeared in recent nonce window
9. handshake attempts in last 10 seconds do not exceed 10
### `auth_success`
Sent by `Yonexus.Server` on success.
Example:
```text
builtin::{
"type":"auth_success",
"requestId":"req_003",
"timestamp":1711886501,
"payload":{
"identifier":"client-a",
"authenticatedAt":1711886501,
"status":"online"
}
}
```
### `auth_failed`
Sent by `Yonexus.Server` on auth failure.
Allowed reasons include:
- `unknown_identifier`
- `not_paired`
- `invalid_signature`
- `invalid_secret`
- `stale_timestamp`
- `future_timestamp`
- `nonce_collision`
- `rate_limited`
- `re_pair_required`
### `re_pair_required`
Sent by `Yonexus.Server` when trust state must be reset.
Example:
```text
builtin::{
"type":"re_pair_required",
"requestId":"req_004",
"timestamp":1711886510,
"payload":{
"identifier":"client-a",
"reason":"nonce_collision"
}
}
```
---
## 5.4 Heartbeat
### `heartbeat`
Sent by `Yonexus.Client` every 5 minutes after authentication.
Example:
```text
builtin::{
"type":"heartbeat",
"timestamp":1711886800,
"payload":{
"identifier":"client-a",
"status":"alive"
}
}
```
### `heartbeat_ack`
Optional response by `Yonexus.Server`.
Example:
```text
builtin::{
"type":"heartbeat_ack",
"timestamp":1711886801,
"payload":{
"identifier":"client-a",
"status":"online"
}
}
```
---
## 5.5 Status / Lifecycle Notifications
### `status_update`
Sent by `Yonexus.Server` when client state changes.
Example:
```text
builtin::{
"type":"status_update",
"timestamp":1711887220,
"payload":{
"identifier":"client-a",
"status":"unstable",
"reason":"heartbeat_timeout_7m"
}
}
```
### `disconnect_notice`
Sent by `Yonexus.Server` before deliberate close.
Example:
```text
builtin::{
"type":"disconnect_notice",
"timestamp":1711887460,
"payload":{
"identifier":"client-a",
"reason":"heartbeat_timeout_11m"
}
}
```
---
## 5.6 Errors
### `error`
Generic protocol-level error.
Recommended builtin error codes:
- `MALFORMED_MESSAGE`
- `UNSUPPORTED_PROTOCOL_VERSION`
- `IDENTIFIER_NOT_ALLOWED`
- `PAIRING_REQUIRED`
- `PAIRING_EXPIRED`
- `ADMIN_NOTIFICATION_FAILED`
- `AUTH_FAILED`
- `NONCE_COLLISION`
- `RATE_LIMITED`
- `RE_PAIR_REQUIRED`
- `CLIENT_OFFLINE`
- `INTERNAL_ERROR`
---
## 6. State Machines
## 6.1 Client State Machine
Suggested `Yonexus.Client` states:
- `idle`
- `connecting`
- `connected`
- `pairing_required`
- `pairing_pending`
- `paired`
- `authenticating`
- `authenticated`
- `reconnecting`
- `error`
Typical transitions:
```text
idle
-> connecting
-> connected
-> (pairing_required | authenticating)
pairing_required
-> pairing_pending
-> paired
-> authenticating
-> authenticated
authenticated
-> reconnecting
-> connecting
```
On `re_pair_required`:
```text
authenticated | authenticating -> pairing_required
```
## 6.2 Server-Side Client State
Per client trust state:
- `unpaired`
- `pending`
- `paired`
- `revoked`
Per client liveness state:
- `online`
- `unstable`
- `offline`
---
## 7. Security Windows and Replay Protection
## 7.1 Nonce Requirements
Nonce rules:
- exactly 24 random characters
- fresh per auth attempt
- must not repeat within recent security window
## 7.2 Recent Nonce Window
Server stores for each client:
- the last 10 nonces seen within the recent validity window
If a nonce collides:
- authentication fails
- server marks condition unsafe
- client must re-pair
## 7.3 Handshake Attempt Window
Server stores recent handshake attempt timestamps.
If more than 10 handshake attempts occur within 10 seconds:
- authentication fails
- server marks condition unsafe
- client must re-pair
## 7.4 Time Drift Validation
Server validates:
```text
abs(current_utc_unix_time - proofTimestamp) < 10
```
If validation fails:
- auth fails
- no session is established
---
## 8. Rule Message Dispatch
All non-builtin messages use:
```text
${rule_identifier}::${message_content}
```
Client to server example:
```text
chat_sync::{"conversationId":"abc","body":"hello"}
```
Server rewrites before matching:
```text
chat_sync::client-a::{"conversationId":"abc","body":"hello"}
```
Dispatch algorithm:
1. parse first delimiter section as `rule_identifier`
2. if `rule_identifier === builtin`, route to builtin protocol handler
3. otherwise iterate registered rules in registration order
4. invoke the first exact match
5. ignore/log if no match is found
Processor input:
- on client: `${rule_identifier}::${message_content}`
- on server for client-originated messages: `${rule_identifier}::${sender_identifier}::${message_content}`
---
## 9. Connection Rules
Server should reject connection attempts when:
- identifier is absent
- identifier is not in configured allowlist
- protocol version is unsupported
- hello/auth payload is malformed
Recommended v1 policy:
- only one active authenticated connection per client identifier
- terminate old connection and accept new one after successful auth
---
## 10. Persistence Semantics
## 10.1 Yonexus.Server Persists
At minimum:
- identifier
- public key
- secret
- trust state
- pairing code + expiry if pending
- pairing notification metadata
- last known liveness status
- metadata timestamps
May persist or reset on restart:
- recent nonces
- recent handshake attempts
Recommended v1:
- clear rolling security windows on restart
- keep long-lived trust records
## 10.2 Yonexus.Client Persists
At minimum:
- identifier
- private key
- secret
- optional last successful pair/auth metadata
---
## 11. Versioning
Protocol version is advertised during `hello`.
Initial version:
```text
1
```
---
## 12. Canonical JSON Shapes
```ts
interface HelloPayload {
identifier: string;
hasSecret: boolean;
hasKeyPair: boolean;
publicKey?: string;
protocolVersion: string;
}
interface PairRequestPayload {
identifier: string;
expiresAt: number;
ttlSeconds: number;
adminNotification: "sent" | "failed";
codeDelivery: "out_of_band";
}
interface PairConfirmPayload {
identifier: string;
pairingCode: string;
}
interface PairSuccessPayload {
identifier: string;
secret: string;
pairedAt: number;
}
interface AuthRequestPayload {
identifier: string;
nonce: string;
proofTimestamp: number;
signature: string;
publicKey?: string;
}
interface HeartbeatPayload {
identifier: string;
status: "alive";
}
```
---
## 13. Example Flows
## 13.1 First-Time Pairing Flow
```text
Client connects WS
Client -> builtin::hello
Server sends Discord DM to configured admin with identifier + pairingCode
Server -> builtin::hello_ack(nextAction=pair_required)
Server -> builtin::pair_request(expiresAt, adminNotification=sent, codeDelivery=out_of_band)
Human reads DM and relays pairingCode to client side
Client -> builtin::pair_confirm(pairingCode)
Server -> builtin::pair_success(secret)
Client stores secret
Client -> builtin::auth_request(signature over secret+nonce+timestamp)
Server -> builtin::auth_success
Client enters authenticated state
```
## 13.2 Normal Reconnect Flow
```text
Client connects WS
Client -> builtin::hello(hasSecret=true)
Server -> builtin::hello_ack(nextAction=auth_required)
Client -> builtin::auth_request(...)
Server -> builtin::auth_success
Client begins heartbeat schedule
```
## 13.3 Unsafe Replay / Collision Flow
```text
Client -> builtin::auth_request(nonce collision)
Server detects unsafe condition
Server -> builtin::re_pair_required(reason=nonce_collision)
Server invalidates existing secret/session trust
Server sends fresh Discord DM pairing notification on next pairing start
Client returns to pairing_required state
```
## 13.4 Heartbeat Timeout Flow
```text
Client authenticated
No heartbeat for 7 min -> server marks unstable
No heartbeat for 11 min -> server marks offline
Server -> builtin::disconnect_notice
Server closes WS connection
```
---
## 14. Implementation Notes
## 14.1 Parsing
Because the wire format is string-based with `::` delimiters:
- only the first delimiter split should determine the `rule_identifier`
- for `builtin`, the remainder is parsed as JSON once
- message content itself may contain `::`, so avoid naive full split logic
## 14.2 Discord DM Notification
When pairing starts on `Yonexus.Server`:
- use configured `notifyBotToken`
- send DM to `adminUserId`
- include only required pairing data
- if DM send fails, surface pairing notification failure
Sensitive values that must never be logged in plaintext:
- `secret`
- private key
- raw proof material
---
## 15. Open Clarifications
1. Exact signing algorithm: Ed25519 is a strong default candidate
2. Secret length and generation requirements
3. Pairing code format and length
4. Is human code relay enough for v1, or should admin approve/deny controls be added later?
5. Should `heartbeat_ack` be mandatory or optional?
6. Should client reconnect use exponential backoff?
7. Should duplicate active connections replace old sessions or be rejected in stricter modes?
---
## 16. Reserved Builtin Types
Reserved builtin `type` values:
- `hello`
- `hello_ack`
- `pair_request`
- `pair_confirm`
- `pair_success`
- `pair_failed`
- `auth_request`
- `auth_success`
- `auth_failed`
- `re_pair_required`
- `heartbeat`
- `heartbeat_ack`
- `status_update`
- `disconnect_notice`
- `error`
These names are reserved by Yonexus and must not be repurposed by user rules.

245
README.md
View File

@@ -1,171 +1,118 @@
[English](./README.md) | [中文](./README.zh.md)
---
# Yonexus
Yonexus is a cross-instance communication system for OpenClaw built as **three separate repositories**:
Yonexus is an OpenClaw plugin for organization hierarchy and agent identity management.
| Repository | Role |
|---|---|
| `Yonexus` | Umbrella — architecture, planning, and coordination |
| `Yonexus.Server` | Central hub plugin — accepts client connections, handles pairing/authentication |
| `Yonexus.Client` | Client plugin — connects to server, manages local identity |
| `Yonexus.Protocol` | Shared protocol specification — referenced as a submodule by both Server and Client |
## Features
## Overview
- Organization hierarchy: `Organization -> Department -> Team -> Agent`
- Filesystem-backed resource layout under `${openclaw dir}/yonexus`
- Agent registration and multi-identity assignment
- Supervisor relationship mapping (does **not** imply permissions)
- Role-based authorization
- Query DSL: `eq | contains | regex`
- Queryable field whitelist via schema (`queryable: true`)
- Scope shared memory adapter (`org/dept/team`)
- JSON persistence for structure data
- Audit logs and structured error codes
- Import / export support
### Yonexus.Server
Installed on the central OpenClaw instance.
## Project Layout
Responsibilities:
- run the WebSocket server
- maintain the client registry
- handle pairing and authentication
- track heartbeat and liveness state
- relay messages to connected clients
- rewrite inbound client messages before rule dispatch
- notify a human administrator of pairing requests via Discord DM
### Yonexus.Client
Installed on follower OpenClaw instances.
Responsibilities:
- connect to `Yonexus.Server`
- manage local keypair and shared secret
- complete pairing with out-of-band pairing code
- authenticate on reconnect
- send periodic heartbeat
- send messages to server
- receive messages from server via rule dispatch
### Yonexus.Protocol
Shared protocol specification repository. Both `Yonexus.Server` and `Yonexus.Client` reference this as a submodule at `protocol/`.
---
## Repository Structure
```
Yonexus (umbrella)
├── Yonexus.Protocol ← shared protocol submodule
├── Yonexus.Server ← server plugin submodule
│ └── protocol/ ← points to Yonexus.Protocol
└── Yonexus.Client ← client plugin submodule
└── protocol/ ← points to Yonexus.Protocol
```
---
## Architecture
A Yonexus network contains:
- exactly one OpenClaw instance running `Yonexus.Server`
- one or more OpenClaw instances running `Yonexus.Client`
Topology rules:
- `Yonexus.Server` must be reachable via a stable address
- `Yonexus.Client` instances connect outbound to the server
- direct client-to-client sockets are not required in v1
- client-to-client communication, if needed, is relayed by the server
---
## Security Model
Pairing is intentionally **out-of-band**.
When a new client needs pairing:
- the server generates a pairing code
- the server sends that pairing code by Discord DM to a configured human admin
- the pairing code is **not** sent over the Yonexus WebSocket connection
- the human relays the code to the client side manually
- the client submits the code back through the protocol
After pairing:
- the server issues a shared secret
- the client stores its private key and secret locally
- reconnect authentication uses signed proof derived from `secret + nonce + timestamp`
---
## Current Repository Spec Files
### Umbrella (`Yonexus`)
- `PLAN.md` — project plan and architecture
- `ARCHITECTURE.md` — architecture overview and repository graph
- `FEAT.md` — implementation feature checklist
### Protocol (`Yonexus.Protocol`)
- `PROTOCOL.md` — shared communication protocol specification
### Server (`Yonexus.Server`)
- `PLAN.md` — server-specific implementation plan
- `protocol/` — submodule pointing to `Yonexus.Protocol`
### Client (`Yonexus.Client`)
- `PLAN.md` — client-specific implementation plan
- `protocol/` — submodule pointing to `Yonexus.Protocol`
---
## Planned TypeScript APIs
### Yonexus.Server
```ts
sendMessageToClient(identifier: string, message: string): Promise<void>
registerRule(rule: string, processor: (message: string) => unknown): void
```
### Yonexus.Client
```ts
sendMessageToServer(message: string): Promise<void>
registerRule(rule: string, processor: (message: string) => unknown): void
```
Message format:
```text
${rule_identifier}::${message_content}
.
├─ plugin/
│ ├─ index.ts # wiring: init, register commands/hooks/tools
│ ├─ commands/ # slash commands
│ ├─ tools/ # query & resource tools
│ ├─ hooks/ # lifecycle hooks
│ └─ core/ # business logic, models, store, permissions
├─ skills/ # skill definitions
├─ docs/ # project documentation
├─ scripts/ # demo & utility scripts
├─ tests/ # tests
├─ install.mjs # install/uninstall script
├─ plugin.json # plugin manifest
├─ README.md
└─ README.zh.md
```
Reserved rule: `builtin`
## Requirements
---
- Node.js 22+
- npm 10+
## Planned Server Config
## Quick Start
```json
{
"followerIdentifiers": ["client-a", "client-b"],
"notifyBotToken": "<discord-bot-token>",
"adminUserId": "123456789012345678",
"listenHost": "0.0.0.0",
"listenPort": 8787,
"publicWsUrl": "wss://example.com/yonexus"
}
```bash
npm install
npm run build
npm run test:smoke
npm run demo
```
## Planned Client Config
## Install / Uninstall
```json
{
"mainHost": "wss://example.com/yonexus",
"identifier": "client-a",
"notifyBotToken": "<discord-bot-token>",
"adminUserId": "123456789012345678"
}
```bash
# Install (builds and copies to ~/.openclaw/plugins/yonexus)
node install.mjs --install
# Install to custom openclaw profile path
node install.mjs --install --openclaw-profile-path /path/to/.openclaw
# Uninstall
node install.mjs --uninstall
```
---
## Configuration
## Status
`plugin.json` includes default config:
- planning/specification stage
- split-plugin architecture defined
- protocol draft defined in `Yonexus.Protocol`
- implementation not started yet
- `name`: `yonexus`
- `entry`: `dist/yonexus/index.js`
- `config.dataFile`: `./data/org.json`
- `config.registrars`: whitelist for registrar agents
- `config.schema`: metadata field schema and queryability
---
## Implemented APIs
## Repository URLs
Core:
- `createOrganization(actor, name)`
- `createDepartment(actor, name, orgId)`
- `createTeam(actor, name, deptId)`
- `registerAgent(actor, agentId, name, roles?)`
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
- `whoami(agentId)`
- `queryAgents(actor, scope, query)`
- [Yonexus (umbrella)](https://git.hangman-lab.top/nav/Yonexus)
- [Yonexus.Server](https://git.hangman-lab.top/nav/Yonexus.Server)
- [Yonexus.Client](https://git.hangman-lab.top/nav/Yonexus.Client)
- [Yonexus.Protocol](https://git.hangman-lab.top/nav/Yonexus.Protocol)
Management:
- `renameDepartment(actor, deptId, newName)`
- `renameTeam(actor, teamId, newName, deptId?)`
- `migrateTeam(actor, teamId, newDeptId)`
- `deleteDepartment(actor, deptId)`
- `deleteTeam(actor, teamId, deptId?)`
Docs:
- `getDocs(scope, topic, keyword)`
Data & audit:
- `exportData(actor)`
- `importData(actor, state)`
- `listAuditLogs(limit?, offset?)`
## Testing
```bash
npm run test:smoke
```
## Notes
- Structure data is persisted in JSON, not memory_store.
- Shared scope memory is handled via the scope memory adapter.
- Unknown metadata fields are dropped during identity assignment.
- `queryAgents` enforces schema queryable constraints.

118
README.zh.md Normal file
View File

@@ -0,0 +1,118 @@
[English](./README.md) | [中文](./README.zh.md)
---
# Yonexus
Yonexus 是一个用于 OpenClaw 的组织结构与 Agent 身份管理插件。
## 功能特性
- 组织层级:`Organization -> Department -> Team -> Agent`
- 基于文件系统的资源目录:`${openclaw dir}/yonexus`
- Agent 注册与多身份Identity管理
- 上下级关系Supervisor**不自动赋权**
- 基于角色的权限控制
- Query DSL`eq | contains | regex`
- 基于 schema 的可查询字段白名单(`queryable: true`
- scope 共享记忆适配org/dept/team
- 结构化数据 JSON 持久化
- 审计日志与结构化错误码
- 导入 / 导出能力
## 项目结构
```text
.
├─ plugin/
│ ├─ index.ts # 接线层:初始化、注册命令/hooks/tools
│ ├─ commands/ # slash commands
│ ├─ tools/ # 查询与资源工具
│ ├─ hooks/ # 生命周期钩子
│ └─ core/ # 业务逻辑、模型、存储、权限
├─ skills/ # 技能定义
├─ docs/ # 项目文档
├─ scripts/ # 演示与工具脚本
├─ tests/ # 测试
├─ install.mjs # 安装/卸载脚本
├─ plugin.json # 插件清单
├─ README.md
└─ README.zh.md
```
## 环境要求
- Node.js 22+
- npm 10+
## 快速开始
```bash
npm install
npm run build
npm run test:smoke
npm run demo
```
## 安装 / 卸载
```bash
# 安装(构建并复制到 ~/.openclaw/plugins/yonexus
node install.mjs --install
# 安装到自定义 openclaw profile 路径
node install.mjs --install --openclaw-profile-path /path/to/.openclaw
# 卸载
node install.mjs --uninstall
```
## 配置说明
`plugin.json` 默认包含以下配置:
- `name`: `yonexus`
- `entry`: `dist/yonexus/index.js`
- `config.dataFile`: `./data/org.json`
- `config.registrars`: 注册人白名单
- `config.schema`: 元数据字段定义与可查询性
## 已实现 API
核心 API
- `createOrganization(actor, name)`
- `createDepartment(actor, name, orgId)`
- `createTeam(actor, name, deptId)`
- `registerAgent(actor, agentId, name, roles?)`
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
- `whoami(agentId)`
- `queryAgents(actor, scope, query)`
管理 API
- `renameDepartment(actor, deptId, newName)`
- `renameTeam(actor, teamId, newName, deptId?)`
- `migrateTeam(actor, teamId, newDeptId)`
- `deleteDepartment(actor, deptId)`
- `deleteTeam(actor, teamId, deptId?)`
文档检索:
- `getDocs(scope, topic, keyword)`
数据与审计:
- `exportData(actor)`
- `importData(actor, state)`
- `listAuditLogs(limit?, offset?)`
## 测试
```bash
npm run test:smoke
```
## 说明
- 结构数据保存在 JSON 文件,不进入 memory_store。
- 共享记忆通过 scope memory 适配器处理。
- 分配 identity 时,未知 meta 字段会被丢弃。
- `queryAgents` 会严格校验字段是否在 schema 中标记为可查询。

View File

@@ -1,811 +0,0 @@
# Yonexus — TASKLIST
基于 `FEAT.md``PLAN.md``PROTOCOL.md``README.md` 整理。
目标:把 Yonexus 从“规划完成、实现未开始”的状态,拆成可逐项交付、可验收、尽量低耦合的小任务列表。
---
## 0. 任务拆分原则
- 先把**协议、边界、脚手架**固定,再做运行时逻辑
- 先做**可启动、可连接**,再做**配对/认证**
- 先做**核心 happy path**,再做**失败路径和安全硬化**
- 先做**Server / Client 各自最小闭环**,再做联调
- 每个任务尽量满足:
- 范围明确
- 可以单独提交
- 有清晰验收标准
---
## Phase 0 — 仓库与规范对齐
### YNX-0001 统一仓库定位与术语
**目标**
- 统一文档中对 Yonexus、Yonexus.Server、Yonexus.Client、Yonexus.Protocol 的描述
- 明确这是“umbrella + 三个独立仓库/子模块”的模型
**子任务**
- 检查 `README.md``PLAN.md``FEAT.md``PROTOCOL.md` 术语是否一致
- 明确 `builtin``rule_identifier``identifier``secret``pairingCode` 等词汇定义
- 记录 v1 非目标,避免实现漂移
**验收标准**
- 核心文档不存在角色冲突或架构冲突描述
- 协议字段名称在文档中保持一致
---
### YNX-0002 定义 v1 实现边界
**目标**
- 把“必须做”和“以后再说”彻底分开
**子任务**
- 固化 v1 必做项WebSocket、pairing、auth、heartbeat、rule dispatch、持久化
- 固化 v1 不做项:多服务器、离线消息队列、复杂规则匹配、管理 UI
- 明确心跳 ack 是否默认开启
- 明确断线重连采用指数退避还是固定退避
**验收标准**
- 有一份可执行的 v1 scope 列表
- 实现阶段不再反复讨论边界
---
## Phase 1 — 协议落地与共享契约
### YNX-0101 固化 builtin 协议信封与类型枚举
**目标**
-`PROTOCOL.md` 里的 builtin envelope 转成共享 TypeScript 类型
**子任务**
- 定义 `BuiltinEnvelope`
- 定义所有 builtin `type` 的联合类型
- 定义 payload 接口:`HelloPayload``PairRequestPayload``PairConfirmPayload``PairSuccessPayload``AuthRequestPayload``HeartbeatPayload`
- 统一 `timestamp` 使用 UTC unix seconds
**产出**
- `Yonexus.Protocol` 中的共享类型文件
**验收标准**
- Server / Client 都能直接复用类型
- 所有 builtin 消息都可被类型系统约束
---
### YNX-0102 实现协议编解码工具
**目标**
- 提供统一的字符串协议解析与序列化能力
**子任务**
- 实现 `encodeBuiltin(envelope)`
- 实现 `decodeBuiltin(raw)`
- 实现 rule message 的首段分隔解析
- 确保 message content 中包含 `::` 时不会被错误切碎
- 为 malformed message 提供标准错误
**验收标准**
- `builtin::{json}` 可稳定双向转换
- `rule::content``rule::sender::content` 都能正确解析
---
### YNX-0103 定义协议错误码与错误对象
**目标**
- 统一错误语义,避免 Server / Client 各自发明错误格式
**子任务**
- 定义协议错误码枚举
- 定义错误 envelope payload 结构
- 区分:配置错误、协议错误、认证错误、配对错误、运行时错误
**验收标准**
- 所有失败路径都能落到有限集合的错误码
- 文档与代码错误码一致
---
### YNX-0104 编写协议级测试样例
**目标**
- 在实现运行时前,先锁定协议行为
**子任务**
- 为 hello / pair / auth / heartbeat / error 编写样例用例
- 为 malformed、缺字段、错误 rule、保留字冲突编写反例
-`::` 分隔边界编写测试
**验收标准**
- 协议测试能独立运行
- 后续实现可直接拿这些样例做回归
---
## Phase 2 — Server 插件脚手架
### YNX-0201 创建 Yonexus.Server 最小插件骨架
**目标**
- 让 Server 插件可被 OpenClaw 加载
**子任务**
- 创建插件目录结构
- 创建 `package.json`
- 创建 `openclaw.plugin.json`
- 创建入口文件 `index.ts`
- 添加基础构建配置
**验收标准**
- 插件能被识别
- 启动时至少能加载但不报致命错误
---
### YNX-0202 定义 Yonexus.Server 配置 schema
**目标**
- 让 Server 配置在启动前就能被校验
**子任务**
- 校验 `followerIdentifiers`
- 校验 `notifyBotToken`
- 校验 `adminUserId`
- 校验 `listenPort`
- 校验 `listenHost` / `publicWsUrl` 可选字段
- 启动失败时返回明确错误
**验收标准**
- 缺字段和非法字段会 fail fast
- 错误信息足够定位问题
---
### YNX-0203 实现 Server 生命周期 wiring
**目标**
- 在 gateway 启动时初始化 Server 内部组件
**子任务**
- 初始化配置
- 初始化 registry
- 初始化持久化层
- 初始化 rule registry
- 注册 shutdown 清理逻辑
**验收标准**
- 启停流程完整
- 不会留下悬挂 timer / socket
---
## Phase 3 — Client 插件脚手架
### YNX-0301 创建 Yonexus.Client 最小插件骨架
**目标**
- 让 Client 插件可被 OpenClaw 加载
**子任务**
- 创建插件目录结构
- 创建 `package.json`
- 创建 `openclaw.plugin.json`
- 创建入口文件 `index.ts`
- 添加基础构建配置
**验收标准**
- 插件能被识别并加载
---
### YNX-0302 定义 Yonexus.Client 配置 schema
**目标**
- 保证 Client 配置可启动前校验
**子任务**
- 校验 `mainHost`
- 校验 `identifier`
- 校验 `notifyBotToken`
- 校验 `adminUserId`
- 规范 `mainHost` 是否允许裸 `host:port`
**验收标准**
- 配置错误可在启动时直接发现
---
### YNX-0303 实现 Client 生命周期 wiring
**目标**
- 在 gateway 启动时初始化 Client 运行时
**子任务**
- 加载本地状态
- 初始化连接管理器
- 初始化规则注册器
- 注册 shutdown 清理逻辑
**验收标准**
- Client 可启动并在未连接状态下稳定运行
---
## Phase 4 — 持久化与状态模型
### YNX-0401 定义 Server 持久化记录结构
**目标**
-`PLAN.md` 中的 `ClientRecord` 落到代码
**子任务**
- 定义 paired/unpaired/revoked/pending 状态
- 定义 heartbeat/liveness 字段
- 定义 pairing 通知字段
- 定义 recent nonce / recent attempts 窗口结构
- 定义 `createdAt` / `updatedAt`
**验收标准**
- 持久化结构足以支撑 pairing/auth/heartbeat
---
### YNX-0402 实现 Server 状态存储
**目标**
- 让信任状态在重启后仍可恢复
**子任务**
- 选择 JSON 文件或等价轻量持久化形式
- 实现加载、保存、原子写入
- 区分持久数据与内存态数据
- 明确 restart 后是否清空 rolling windows
**验收标准**
- 重启后 paired client 不丢失
- 损坏文件时有可恢复行为
---
### YNX-0403 实现 Client 本地信任材料存储
**目标**
- 持久化 Client 的 `identifier`、私钥、secret
**子任务**
- 定义本地 state 文件结构
- 实现加载 / 初始化 / 保存
- 缺失文件时自动初始化最小状态
- 标记敏感字段
**验收标准**
- Client 重启后可恢复身份与 secret
---
## Phase 5 — Transport 最小闭环
### YNX-0501 实现 Server WebSocket 启动与连接接入
**目标**
- Server 能监听并接受连接
**子任务**
- 启动 WebSocket server
- 处理 connect / message / close / error
- 记录连接与 identifier 绑定前的临时会话
**验收标准**
- 可看到客户端连接进入
- 无协议时也不会崩
---
### YNX-0502 实现 Client WebSocket 连接器
**目标**
- Client 能主动连到 Server
**子任务**
- 建立 outbound 连接
- 处理 open / message / close / error
- 提供连接状态机
**验收标准**
- Client 能连上可用的 Server
- Server 不可用时不会死循环刷日志
---
### YNX-0503 实现 hello / hello_ack
**目标**
- 完成连接后的第一段协议握手
**子任务**
- Client 连接后发送 `hello`
- Server 验证 `identifier`、协议版本、payload 形状
- Server 返回 `hello_ack`
- 根据状态决定 `nextAction`
**验收标准**
- 未配对客户端收到 `pair_required`
- 已配对客户端收到 `auth_required`
- 非 allowlist 客户端被拒绝
---
### YNX-0504 实现基础重连策略
**目标**
- Client 断线后可恢复连接
**子任务**
- 设计退避策略
- 避免并发多次重连
- 在成功连接后重置退避计数
**验收标准**
- 断线可自动恢复
- 不会形成高频重连风暴
---
## Phase 6 — Pairing 主流程
### YNX-0601 实现 Client 首次密钥生成
**目标**
- Client 首次运行自动生成本地公私钥
**子任务**
- 选定签名算法(建议 Ed25519
- 生成 keypair
- 保存私钥
- 导出公钥供握手使用
**验收标准**
- 首次启动可生成并持久化 keypair
- 重启不会重复生成
---
### YNX-0602 实现 Server pairing request 创建
**目标**
- Server 可为待配对客户端创建 pairing 会话
**子任务**
- 生成 pairing code
- 生成 expiresAt / ttl
- 将状态写入 registry
- 更新 pairing 状态为 pending
**验收标准**
- 每次 pairing 会话有可验证的过期时间
- pairing code 不会通过 Yonexus WS 下发
---
### YNX-0603 实现 Discord DM 配对通知
**目标**
- Server 通过 `notifyBotToken``adminUserId` 发送 pairing code
**子任务**
- 封装 Discord DM 发送逻辑
- DM 内容包含 identifier、pairingCode、expiresAt/TTL
- 处理发送失败
- 记录 pairing notification metadata
**验收标准**
- 通知成功时 Client 才能进入可确认状态
- 通知失败时不会继续配对成功路径
---
### YNX-0604 实现 pair_request / pair_confirm / pair_success
**目标**
- 打通完整配对流程
**子任务**
- Server 下发不含 code 的 `pair_request`
- Client 接收并进入 pairing pending
- Client 提交 `pair_confirm`
- Server 校验 code 和 expiry
- 成功后生成 secret
- Server 返回 `pair_success`
- Client 保存 secret
**验收标准**
- 正确 code 可完成配对
- 错误 code / 过期 code 会失败
---
### YNX-0605 实现配对失败路径
**目标**
- 补齐 pairing 相关失败逻辑
**子任务**
- `invalid_code`
- `expired`
- `identifier_not_allowed`
- `admin_notification_failed`
- `internal_error`
- 配对失败后的状态恢复策略
**验收标准**
- 失败后不会留下脏状态导致后续无法重试
---
## Phase 7 — Authentication 主流程
### YNX-0701 固化 proof 构造与签名规范
**目标**
- 明确签名输入,避免 Server / Client 不一致
**子任务**
- 决定 canonical serialization 方案
- 明确签名字段secret、nonce、timestamp
- 定义签名编码格式(如 base64
**验收标准**
- 同一输入在 Client 与 Server 上验证一致
---
### YNX-0702 实现 Client auth_request
**目标**
- Client 能基于本地 secret 与私钥发起认证
**子任务**
- 生成 24 字符 nonce
- 生成 proofTimestamp
- 构造签名
- 发送 `auth_request`
**验收标准**
- 报文字段完整
- nonce 格式符合协议要求
---
### YNX-0703 实现 Server 认证校验
**目标**
- Server 能验证 auth_request 真伪
**子任务**
- 校验 identifier allowlist
- 校验 paired 状态
- 校验 publicKey 匹配
- 校验 signature
- 校验 secret
- 校验 timestamp 漂移
- 校验 nonce 重放
- 校验 attempts 频率
**验收标准**
- 正确认证返回 `auth_success`
- 各类失败返回对应 `auth_failed`
---
### YNX-0704 实现 re_pair_required 机制
**目标**
- 在不安全条件下强制重新配对
**子任务**
- 检测 nonce collision
- 检测 `>10 attempts / 10s`
- 失效旧 secret 或标记 trust revoked
- 返回 `re_pair_required`
- Client 收到后切回 pairing_required
**验收标准**
- 不安全状态下不会继续接受旧信任材料
---
## Phase 8 — Heartbeat 与在线状态
### YNX-0801 实现 Client heartbeat loop
**目标**
- Client 在认证后按周期发心跳
**子任务**
- 认证成功后启动 heartbeat timer
- 断线或未认证时停止 timer
- 发送 `heartbeat`
**验收标准**
- 默认每 5 分钟发送一次
- 状态切换时 timer 无泄漏
---
### YNX-0802 实现 Server heartbeat 接收与记录
**目标**
- Server 能更新客户端最近存活时间
**子任务**
- 接收 heartbeat
- 校验来源已认证
- 更新 `lastHeartbeatAt`
- 可选返回 `heartbeat_ack`
**验收标准**
- 收到心跳后客户端状态可维持在线
---
### YNX-0803 实现 Server liveness sweep
**目标**
- Server 能周期性评估 online / unstable / offline
**子任务**
- 增加 30~60s sweep timer
- 7 分钟无心跳标记 unstable
- 11 分钟无心跳标记 offline
- offline 时发送 `disconnect_notice` 并断开连接
**验收标准**
- 状态转移符合文档定义
---
## Phase 9 — 规则消息与 API
### YNX-0901 实现 Client rule registry
**目标**
- Client 侧支持应用层规则分发
**子任务**
- 实现 `registerRule(rule, processor)`
- 禁止 `builtin`
- 默认禁止重复注册
- 按 exact match 分发
**验收标准**
- 命中规则时正确调用处理器
- 非法注册被拒绝
---
### YNX-0902 实现 Server rule registry
**目标**
- Server 侧支持应用层规则分发
**子任务**
- 实现 `registerRule(rule, processor)`
- 禁止 `builtin`
- 默认禁止重复注册
- 按 exact match 分发
**验收标准**
- 规则注册与调用行为一致
---
### YNX-0903 实现 `sendMessageToServer(message)`
**目标**
- 暴露 Client 到 Server 的发送 API
**子任务**
- 校验连接/认证状态
- 发送 `${rule_identifier}::${message_content}`
- 未连接时返回明确错误
**验收标准**
- 上层插件可直接调用
---
### YNX-0904 实现 `sendMessageToClient(identifier, message)`
**目标**
- 暴露 Server 到指定 Client 的发送 API
**子任务**
- 校验目标 client 已知且在线
- 发送 `${rule_identifier}::${message_content}`
- 离线时返回明确错误
**验收标准**
- 上层插件可向指定客户端投递消息
---
### YNX-0905 实现 Server 入站消息重写
**目标**
- 把客户端来的消息重写成带 sender 标识的形式
**子任务**
- 解析 `${rule_identifier}::${message_content}`
- 重写为 `${rule_identifier}::${sender_identifier}::${message_content}`
- 再进入 rule dispatch
**验收标准**
- Server 侧处理器能可靠识别消息来源
---
## Phase 10 — 安全与鲁棒性
### YNX-1001 实现敏感信息脱敏日志
**目标**
- 避免 secret、私钥、proof 原文进入日志
**子任务**
- 定义 redaction 工具
- 覆盖 pairing、auth、error 日志路径
- 对外只保留必要上下文
**验收标准**
- 关键敏感值不会明文打印
---
### YNX-1002 实现 malformed / unsupported / unauthorized 防御
**目标**
- 所有非法输入都能被可控拒绝
**子任务**
- malformed json
- 缺字段 payload
- unsupported protocol version
- unauthorized identifier
- 非法 builtin type
**验收标准**
- 错误不会导致进程崩溃
- 客户端能收到明确错误反馈或断开
---
### YNX-1003 实现单 identifier 单活跃连接策略
**目标**
- 同一 client identifier 只允许一个活跃认证连接
**子任务**
- 定义旧连接替换策略
- 新连接成功认证后踢掉旧连接
- 避免竞态导致双在线
**验收标准**
- 任意时刻同一 identifier 只有一个有效 session
---
### YNX-1004 实现重启恢复策略
**目标**
- 重启后行为可预期且文档一致
**子任务**
- 恢复 durable trust records
- 清理或重建 rolling windows
- 明确 pending pairing 如何恢复
- 明确 active connection 状态如何重建
**验收标准**
- 重启后的认证/配对行为稳定可解释
---
## Phase 11 — 测试与联调
### YNX-1101 编写协议单元测试
**目标**
- 覆盖编解码、字段校验、错误码
### YNX-1102 编写 Server 单元测试
**目标**
- 覆盖 registry、pairing、auth、heartbeat sweep
### YNX-1103 编写 Client 单元测试
**目标**
- 覆盖状态机、keypair、auth 构造、heartbeat timer
### YNX-1104 编写 Server-Client 集成测试
**目标**
- 覆盖首次配对、正常重连、认证失败、心跳超时、re-pair
### YNX-1105 编写失败路径测试矩阵
**目标**
- 系统性覆盖 pairing/auth 失败路径
**重点场景**
- pairing code 错误
- pairing 过期
- DM 发送失败
- stale timestamp
- future timestamp
- nonce collision
- handshake rate limit
- unknown identifier
- duplicate connection
**验收标准**
- 核心安全路径都有自动化测试
---
## Phase 12 — 文档与交付
### YNX-1201 补齐 Server README
**目标**
- Server 仓库可独立被安装与使用
### YNX-1202 补齐 Client README
**目标**
- Client 仓库可独立被安装与使用
### YNX-1203 输出部署文档
**目标**
- 写清楚单主多从部署方式、配置示例、配对流程
### YNX-1204 输出运维排障文档
**目标**
- 写清楚常见报错、状态含义、恢复步骤
### YNX-1205 输出协议测试与验收清单
**目标**
- 让后续改动有统一回归基线
---
## 推荐执行顺序(最小闭环)
### Milestone A — 能启动
- YNX-0101
- YNX-0102
- YNX-0201
- YNX-0202
- YNX-0301
- YNX-0302
- YNX-0401
- YNX-0402
- YNX-0403
### Milestone B — 能连接
- YNX-0203
- YNX-0303
- YNX-0501
- YNX-0502
- YNX-0503
- YNX-0504
### Milestone C — 能配对认证
- YNX-0601
- YNX-0602
- YNX-0603
- YNX-0604
- YNX-0605
- YNX-0701
- YNX-0702
- YNX-0703
- YNX-0704
### Milestone D — 能稳定通信
- YNX-0801
- YNX-0802
- YNX-0803
- YNX-0901
- YNX-0902
- YNX-0903
- YNX-0904
- YNX-0905
### Milestone E — 能交付
- YNX-1001
- YNX-1002
- YNX-1003
- YNX-1004
- YNX-1101
- YNX-1102
- YNX-1103
- YNX-1104
- YNX-1105
- YNX-1201
- YNX-1202
- YNX-1203
- YNX-1204
- YNX-1205
---
## 可直接开工的首批任务
如果要我给出“今天就能开始做”的第一批,我建议是:
1. YNX-0101 固化共享协议类型
2. YNX-0102 实现协议编解码工具
3. YNX-0201 创建 Yonexus.Server 插件骨架
4. YNX-0301 创建 Yonexus.Client 插件骨架
5. YNX-0401 定义 Server / Client 状态模型
6. YNX-0503 实现 hello / hello_ack 最小握手
这样最快能拿到一个“能启动、能连接、能判断下一步动作”的基础闭环。

Submodule Yonexus.Client deleted from 5234358cac

Submodule Yonexus.Protocol deleted from 9232aa7c17

Submodule Yonexus.Server deleted from d8290c0aa7

0
data/.gitkeep Normal file
View File

0
docs/.gitkeep Normal file
View File

54
docs/AGENT_TASKS.md Normal file
View File

@@ -0,0 +1,54 @@
# Yonexus — AGENT_TASKS
> 目标:将插件拆解为可执行任务(按阶段/优先级)。
## Phase 0 — 基础准备P0
- [x] 明确插件运行环境/依赖OpenClaw 版本、Node 版本)
- [x] 定义最终配置文件格式schema + permissions + registrars
- [x] 统一 ID 规则org/dept/team/agent
## Phase 1 — MVP 核心P0
### 数据与存储
- [x] 设计数据模型Org/Dept/Team/Agent/Identity/Supervisor
- [x] 实现 in-memory store + JSON 持久化
- [x] 定义 CRUD API
### 权限系统
- [x] 实现权限角色Org Admin / Dept Admin / Team Lead / Agent
- [x] 实现权限校验函数 authorize(action, actor, scope)
- [x] 实现 registrars 白名单(禁止自注册)
### 工具/API
- [x] create_department
- [x] create_team
- [x] register_agent
- [x] assign_identity
- [x] set_supervisor
- [x] whoami
- [x] query_agents
### Query DSL
- [x] filters/op 解析eq / contains / regex
- [x] schema queryable 字段约束
- [x] paginationlimit/offset
### Scope Memory
- [x] scope_memory.put(scopeId, text, metadata)
- [x] scope_memory.search(scopeId, query, limit)
- [x] 兼容 memory-lancedb-pro
## Phase 2 — v1 增强P1
- [x] 模糊/正则性能优化(索引/缓存)
- [x] 管理命令与校验(重命名/删除/迁移)
- [x] 完善错误码与审计日志
- [x] 增加导入/导出工具
## Phase 3 — 体验与文档P1
- [x] README安装/配置/示例)
- [x] 示例数据集与演示脚本
- [x] 安装脚本完善build + copy 到 dist/yonexus
## Risk & Notes
- 结构数据不进 memory_store只做 scope 共享记忆)
- queryable 字段必须严格按 schema 控制
- supervisor 关系不隐含权限

86
docs/FEAT.md Normal file
View File

@@ -0,0 +1,86 @@
# FEAT — Yonexus Feature List
## Existing Features
### Core Model & Storage
- Organization / Department / Team / Agent / Identity / Supervisor data model
- In-memory runtime with JSON persistence (`data/org.json`)
- Import/export of structure data
### Authorization
- Role model: `org_admin`, `dept_admin`, `team_lead`, `agent`
- `authorize(action, actor, scope)` permission check
- Registrar whitelist (`registrars`) and bootstrap registration support
### Core APIs
- `createOrganization(actor, name)`
- `createDepartment(actor, name, orgId)`
- `createTeam(actor, name, deptId)`
- `registerAgent(actor, agentId, name, roles?)`
- `assignIdentity(actor, agentId, deptId, teamId, meta)`
- `setSupervisor(actor, agentId, supervisorId, deptId?)`
- `whoami(agentId)`
- `queryAgents(actor, scope, query)`
### Query & Search
- Query ops: `eq`, `contains`, `regex`
- Schema `queryable` whitelist enforcement
- Pagination (`limit`, `offset`)
- Basic query performance optimization (regex cache + ordered filter eval)
### Management & Audit
- `renameDepartment`, `renameTeam`, `migrateTeam`, `deleteDepartment`, `deleteTeam`
- Structured errors via `YonexusError`
- In-memory audit log (`listAuditLogs`)
### Scope Memory
- Scope memory adapter:
- `scope_memory.put(scopeId, text, metadata)`
- `scope_memory.search(scopeId, query, limit)`
### Developer Experience
- `README.md` + `README.zh.md`
- Example data (`examples/sample-data.json`)
- Demo script (`scripts/demo.ts`)
- Smoke test (`tests/smoke.ts`)
---
## New Features (from NEW_FEAT)
### 1) Filesystem Resource Layout
Data-only filesystem tree under:
- `${openclaw dir}/yonexus/organizations/<org-name>/...`
Auto-create (idempotent):
- On `createOrganization`:
- `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
- On `createTeam`:
- `teams/<team-name>/agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
- On `assignIdentity`:
- `teams/<team-name>/agents/<agent-id>/docs|notes|knowledge|rules|lessons|workflows`
### 2) Document Query Tool
New API:
- `getDocs(scope, topic, keyword)`
Parameters:
- `scope`: `organization | department | team | agent`
- `topic`: `docs | notes | knowledge | rules | lessons | workflows`
- `keyword`: regex string
Behavior:
- Read-only search by filename regex under filesystem resources
- Structured output:
- `----ORG`
- `----DEPT`
- `----TEAM`
- `----AGENT`
- Invalid regex returns structured error (`YonexusError: VALIDATION_ERROR`)
## Notes
- `${openclaw dir}` resolution order:
1. `YonexusOptions.openclawDir`
2. `OPENCLAW_DIR` env
3. `${HOME}/.openclaw`
- Plugin code is not written into `${openclaw dir}/yonexus`; only data folders/files are used there.

127
docs/PLAN.md Normal file
View File

@@ -0,0 +1,127 @@
# Yonexus — Project Plan
## 1) Goal
Build an OpenClaw plugin that models organization hierarchy and agent identities, supports supervisor relationships, provides query tools for agents, and uses shared memory per scope (org/department/team).
## 2) Core Concepts
- **Hierarchy**: Organization → Department → Team → Agent
- **Supervisor**: each agent may have exactly one supervisor
- **Identity**: an agent can hold multiple identities across teams/departments
- **Schema-driven metadata**: configurable fields with per-field queryability
- **Scope memory**: shared memory for org/department/team (using `memory_store`, compatible with memory-lancedb-pro)
## 3) Storage Strategy
- **Structure & identity data**: in-memory + JSON persistence (no memory_store)
- **Shared memory**: memory_store keyed by scope (`org:{id}`, `dept:{id}`, `team:{id}`)
- **Filesystem resources** (OpenClaw install dir `${openclaw dir}`):
- Create a data-only folder at `${openclaw dir}/yonexus` (no plugin code here)
- `yonexus/organizations/<org-name>/` contains: `teams/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
- On **create_organization**: create `<org-name>` folder and its subfolders
- On **create_team**: create `organizations/<org-name>/teams/<team-name>/` with `agents/`, `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
- On **assign_identity**: create `organizations/<org-name>/teams/<team-name>/agents/<agent-id>/` with `docs/`, `notes/`, `knowledge/`, `rules/`, `lessons/`, `workflows/`
## 4) Permissions Model (B)
Roles:
- Org Admin
- Dept Admin
- Team Lead
- Agent
Rules:
- Supervisor is **not** a role (no inherent permissions)
- Registration **not** self-service
- only configured agent list or human via slash command
Permission matrix (recommended):
- create_department → Org Admin
- create_team → Org Admin, Dept Admin (same dept)
- assign_identity → Org Admin, Dept Admin (same dept), Team Lead (same team)
- register_agent → Org Admin, Dept Admin, Team Lead (scope-limited)
- set_supervisor → Org Admin, Dept Admin (same dept)
- query → all roles, but only schema fields with `queryable: true`
## 5) Schema Configuration (example)
```json
{
"position": { "type": "string", "queryable": true },
"discord_user_id": { "type": "string", "queryable": true },
"git_user_name": { "type": "string", "queryable": true },
"department": { "type": "string", "queryable": false },
"team": { "type": "string", "queryable": false }
}
```
## 6) Tool/API Surface (MVP)
- `create_organization(name)`
- `create_department(name, orgId)`
- `create_team(name, deptId)`
- `register_agent(agentId, name)`
- `assign_identity(agentId, deptId, teamId, meta)`
- `set_supervisor(actor, agentId, supervisorId)`
- `whoami(agentId)` → identities + supervisor + roles
- `query_agents(filters, options)` → list; supports `eq | contains | regex`
Query example:
```json
{
"filters": [
{"field":"discord_user_id","op":"eq","value":"123"},
{"field":"git_user_name","op":"regex","value":"^hang"}
],
"options": {"limit": 20, "offset": 0}
}
```
## 7) Data Model (MVP)
- Organization { id, name }
- Department { id, name, orgId }
- Team { id, name, deptId }
- Agent { id, name, roles[] }
- Identity { id, agentId, deptId, teamId, meta }
- Supervisor { agentId, supervisorId }
## 8) Milestones
**Phase 0 (Design)**
- finalize schema
- confirm permission rules
**Phase 1 (MVP)**
- storage + JSON persistence
- core models + tools
- query DSL
- scope memory adapter
**Phase 2 (v1)**
- policy refinements
- better query pagination & filtering
- management commands & validation
## 9) Project Structure (recommended)
```
openclaw-plugin-yonexus/
├─ plugin.json
├─ src/
│ ├─ index.ts
│ ├─ store/ # in-memory + JSON persistence
│ ├─ models/
│ ├─ permissions/
│ ├─ tools/
│ ├─ memory/
│ └─ utils/
├─ scripts/
│ └─ install.sh
├─ dist/
│ └─ yonexus/ # build output target
└─ data/
└─ org.json
```
## 10) Install Script Requirement
- Provide `scripts/install.sh`
- It should register the OpenClaw plugin name as **`yonexus`**
- Build artifacts must be placed into **`dist/yonexus`**
## 11) Notes & Decisions
- Structure data is not stored in memory_store.
- Shared memory uses memory_store (compatible with memory-lancedb-pro).
- Queryable fields are whitelisted via schema.

44
examples/sample-data.json Normal file
View File

@@ -0,0 +1,44 @@
{
"organizations": [
{ "id": "org:yonexus", "name": "Yonexus" }
],
"departments": [
{ "id": "dept:platform", "name": "Platform", "orgId": "org:yonexus" },
{ "id": "dept:ai", "name": "AI", "orgId": "org:yonexus" }
],
"teams": [
{ "id": "team:platform-core", "name": "Core", "deptId": "dept:platform" },
{ "id": "team:ai-agent", "name": "Agent", "deptId": "dept:ai" }
],
"agents": [
{ "id": "orion", "name": "Orion", "roles": ["org_admin", "agent"] },
{ "id": "hangman", "name": "Hangman", "roles": ["agent"] }
],
"identities": [
{
"id": "identity:orion-platform",
"agentId": "orion",
"deptId": "dept:platform",
"teamId": "team:platform-core",
"meta": {
"position": "assistant",
"discord_user_id": "1474088632750047324",
"git_user_name": "orion"
}
},
{
"id": "identity:hangman-ai",
"agentId": "hangman",
"deptId": "dept:ai",
"teamId": "team:ai-agent",
"meta": {
"position": "owner",
"discord_user_id": "561921120408698910",
"git_user_name": "hangman"
}
}
],
"supervisors": [
{ "agentId": "orion", "supervisorId": "hangman" }
]
}

209
install.mjs Normal file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* Yonexus Plugin Installer v0.2.0
*
* Usage:
* node install.mjs --install
* node install.mjs --install --openclaw-profile-path /path/to/.openclaw
* node install.mjs --uninstall
* node install.mjs --uninstall --openclaw-profile-path /path/to/.openclaw
*/
import { execSync } from 'child_process';
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolve(dirname(__filename));
const PLUGIN_NAME = 'yonexus';
const SRC_DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME);
// ── Parse arguments ─────────────────────────────────────────────────────
const args = process.argv.slice(2);
const isInstall = args.includes('--install');
const isUninstall = args.includes('--uninstall');
const profileIdx = args.indexOf('--openclaw-profile-path');
let openclawProfilePath = null;
if (profileIdx !== -1 && args[profileIdx + 1]) {
openclawProfilePath = resolve(args[profileIdx + 1]);
}
function resolveOpenclawPath() {
if (openclawProfilePath) return openclawProfilePath;
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
return join(homedir(), '.openclaw');
}
// ── Colors ──────────────────────────────────────────────────────────────
const c = {
reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m',
yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m',
};
function log(msg, color = 'reset') { console.log(`${c[color]}${msg}${c.reset}`); }
function logOk(msg) { log(`${msg}`, 'green'); }
function logWarn(msg) { log(`${msg}`, 'yellow'); }
function logErr(msg) { log(`${msg}`, 'red'); }
// ── Helpers ─────────────────────────────────────────────────────────────
function copyDir(src, dest) {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src, { withFileTypes: true })) {
const s = join(src, entry.name);
const d = join(dest, entry.name);
if (entry.name === 'node_modules') continue;
entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d);
}
}
// ── Install ─────────────────────────────────────────────────────────────
function install() {
console.log('');
log('╔══════════════════════════════════════════════╗', 'cyan');
log('║ Yonexus Plugin Installer v0.2.0 ║', 'cyan');
log('╚══════════════════════════════════════════════╝', 'cyan');
console.log('');
// 1. Build
log('[1/4] Building...', 'cyan');
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
execSync('npm run build', { cwd: __dirname, stdio: 'inherit' });
if (!existsSync(SRC_DIST_DIR)) {
logErr(`Build output not found at ${SRC_DIST_DIR}`);
process.exit(1);
}
logOk('Build complete');
// 2. Copy to plugins dir
log('[2/4] Installing...', 'cyan');
const openclawPath = resolveOpenclawPath();
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
log(` OpenClaw path: ${openclawPath}`, 'blue');
if (existsSync(destDir)) rmSync(destDir, { recursive: true, force: true });
copyDir(SRC_DIST_DIR, destDir);
logOk(`Plugin files → ${destDir}`);
// 3. Configure OpenClaw
log('[3/4] Configuring OpenClaw...', 'cyan');
try {
const pluginPath = destDir;
const allow = ensureArray(getConfigValue('plugins.allow'));
const loadPaths = ensureArray(getConfigValue('plugins.load.paths'));
if (!allow.includes(PLUGIN_NAME)) allow.push(PLUGIN_NAME);
if (!loadPaths.includes(pluginPath)) loadPaths.push(pluginPath);
execSync(`openclaw config set plugins.allow '${JSON.stringify(allow)}'`);
execSync(`openclaw config set plugins.load.paths '${JSON.stringify(loadPaths)}'`);
execSync(`openclaw config set plugins.entries.${PLUGIN_NAME}.enabled true`);
execSync(`openclaw config set plugins.entries.${PLUGIN_NAME}.config '{"enabled": true}'`);
logOk('OpenClaw config updated');
} catch (err) {
logErr('Failed to update OpenClaw config via `openclaw config set`');
throw err;
}
// 4. Summary
log('[4/4] Done!', 'cyan');
console.log('');
log('✓ Yonexus installed successfully!', 'green');
console.log('');
log('Next steps:', 'blue');
log(' openclaw gateway restart', 'cyan');
console.log('');
}
function getConfigValue(path) {
try {
const out = execSync(`openclaw config get ${path}`, { encoding: 'utf8' }).trim();
if (!out || out === 'undefined' || out === 'null') return undefined;
try { return JSON.parse(out); } catch { return out; }
} catch {
return undefined;
}
}
function ensureArray(value) {
if (Array.isArray(value)) return value;
if (value === undefined || value === null || value === '') return [];
return [value];
}
// ── Uninstall ───────────────────────────────────────────────────────────
function uninstall() {
console.log('');
log('Uninstalling Yonexus...', 'cyan');
const openclawPath = resolveOpenclawPath();
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
if (existsSync(destDir)) {
rmSync(destDir, { recursive: true, force: true });
logOk(`Removed ${destDir}`);
} else {
logWarn(`${destDir} not found, nothing to remove`);
}
// Clean OpenClaw config
log('Cleaning OpenClaw config...', 'cyan');
try {
const allow = ensureArray(getConfigValue('plugins.allow')).filter((id) => id !== PLUGIN_NAME);
const loadPaths = ensureArray(getConfigValue('plugins.load.paths')).filter((p) => p !== destDir);
if (allow.length > 0) {
execSync(`openclaw config set plugins.allow '${JSON.stringify(allow)}'`);
} else {
execSync('openclaw config unset plugins.allow');
}
if (loadPaths.length > 0) {
execSync(`openclaw config set plugins.load.paths '${JSON.stringify(loadPaths)}'`);
} else {
execSync('openclaw config unset plugins.load.paths');
}
execSync(`openclaw config unset plugins.entries.${PLUGIN_NAME}`);
logOk('OpenClaw config cleaned');
} catch (err) {
logErr('Failed to clean OpenClaw config via `openclaw config`');
throw err;
}
console.log('');
log('✓ Yonexus uninstalled.', 'green');
log('\nNext: openclaw gateway restart', 'yellow');
console.log('');
}
// ── Main ────────────────────────────────────────────────────────────────
if (!isInstall && !isUninstall) {
console.log('');
log('Yonexus Plugin Installer', 'cyan');
console.log('');
log('Usage:', 'blue');
log(' node install.mjs --install Install plugin', 'reset');
log(' node install.mjs --install --openclaw-profile-path <path> Install to custom path', 'reset');
log(' node install.mjs --uninstall Uninstall plugin', 'reset');
log(' node install.mjs --uninstall --openclaw-profile-path <path> Uninstall from custom path', 'reset');
console.log('');
process.exit(1);
}
if (isInstall) install();
if (isUninstall) uninstall();

591
package-lock.json generated Normal file
View File

@@ -0,0 +1,591 @@
{
"name": "openclaw-plugin-yonexus",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-plugin-yonexus",
"version": "0.2.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.13.10",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "openclaw-plugin-yonexus",
"version": "0.2.0",
"description": "Yonexus OpenClaw plugin: hierarchy, identities, permissions, and scoped memory",
"main": "dist/yonexus/index.js",
"types": "dist/yonexus/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json && cp plugin.json dist/yonexus/plugin.json && cp plugin/openclaw.plugin.json dist/yonexus/openclaw.plugin.json",
"clean": "rm -rf dist",
"prepare": "npm run clean && npm run build",
"test:smoke": "tsx tests/smoke.ts",
"demo": "tsx scripts/demo.ts"
},
"keywords": ["openclaw", "plugin", "organization", "agents"],
"license": "MIT",
"devDependencies": {
"typescript": "^5.7.3",
"@types/node": "^22.13.10",
"tsx": "^4.19.2"
}
}

View File

@@ -1,13 +0,0 @@
{
"name": "Yonexus.Client",
"version": "0.1.0",
"description": "Yonexus client plugin for cross-instance OpenClaw communication",
"entry": "dist/client/index.js",
"permissions": [],
"config": {
"mainHost": "",
"identifier": "",
"notifyBotToken": "",
"adminUserId": ""
}
}

21
plugin.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "yonexus",
"version": "0.1.0",
"entry": "dist/yonexus/index.js",
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
"permissions": [
"memory_store",
"memory_recall"
],
"config": {
"dataFile": "./data/org.json",
"registrars": [],
"schema": {
"position": { "type": "string", "queryable": true },
"discord_user_id": { "type": "string", "queryable": true },
"git_user_name": { "type": "string", "queryable": true },
"department": { "type": "string", "queryable": false },
"team": { "type": "string", "queryable": false }
}
}
}

View File

@@ -1,15 +0,0 @@
{
"name": "Yonexus.Server",
"version": "0.1.0",
"description": "Yonexus central server plugin for cross-instance OpenClaw communication",
"entry": "dist/server/index.js",
"permissions": [],
"config": {
"followerIdentifiers": [],
"notifyBotToken": "",
"adminUserId": "",
"listenHost": "0.0.0.0",
"listenPort": 8787,
"publicWsUrl": ""
}
}

0
plugin/commands/.gitkeep Normal file
View File

View File

@@ -0,0 +1,18 @@
import type { StoreState, YonexusSchema } from "../models/types";
export const DEFAULT_STATE: StoreState = {
organizations: [],
departments: [],
teams: [],
agents: [],
identities: [],
supervisors: []
};
export const DEFAULT_SCHEMA: YonexusSchema = {
position: { type: "string", queryable: true },
discord_user_id: { type: "string", queryable: true },
git_user_name: { type: "string", queryable: true },
department: { type: "string", queryable: false },
team: { type: "string", queryable: false }
};

View File

@@ -0,0 +1,16 @@
export interface MemoryPort {
store(input: { text: string; scope: string; metadata?: Record<string, string> }): Promise<unknown>;
recall(input: { query: string; scope: string; limit?: number }): Promise<unknown>;
}
export class ScopeMemory {
constructor(private readonly memory: MemoryPort) {}
async put(scopeId: string, text: string, metadata?: Record<string, string>): Promise<unknown> {
return this.memory.store({ text, scope: scopeId, metadata });
}
async search(scopeId: string, query: string, limit = 5): Promise<unknown> {
return this.memory.recall({ query, scope: scopeId, limit });
}
}

View File

@@ -0,0 +1,10 @@
export interface AuditLogEntry {
id: string;
ts: string;
actorId: string;
action: string;
target?: string;
status: 'ok' | 'error';
message?: string;
meta?: Record<string, unknown>;
}

View File

@@ -0,0 +1,19 @@
export type ErrorCode =
| 'PERMISSION_DENIED'
| 'NOT_FOUND'
| 'ALREADY_EXISTS'
| 'VALIDATION_ERROR'
| 'FIELD_NOT_QUERYABLE'
| 'INVALID_SUPERVISOR'
| 'REGISTRAR_DENIED';
export class YonexusError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = 'YonexusError';
}
}

View File

@@ -0,0 +1,93 @@
export type Role = "org_admin" | "dept_admin" | "team_lead" | "agent";
export interface Organization {
id: string;
name: string;
}
export interface Department {
id: string;
name: string;
orgId: string;
}
export interface Team {
id: string;
name: string;
deptId: string;
}
export interface Agent {
id: string;
name: string;
roles: Role[];
}
export interface Identity {
id: string;
agentId: string;
deptId: string;
teamId: string;
meta: Record<string, string>;
}
export interface Supervisor {
agentId: string;
supervisorId: string;
}
export interface SchemaField {
type: "string";
queryable: boolean;
}
export interface YonexusSchema {
[field: string]: SchemaField;
}
export interface StoreState {
organizations: Organization[];
departments: Department[];
teams: Team[];
agents: Agent[];
identities: Identity[];
supervisors: Supervisor[];
}
export interface Actor {
agentId: string;
}
export type Action =
| "create_organization"
| "create_department"
| "create_team"
| "register_agent"
| "assign_identity"
| "set_supervisor"
| "query_agents";
export type DocsScope = "organization" | "department" | "team" | "agent";
export type DocsTopic = "docs" | "notes" | "knowledge" | "rules" | "lessons" | "workflows";
export interface Scope {
orgId?: string;
deptId?: string;
teamId?: string;
}
export interface QueryFilter {
field: string;
op: "eq" | "contains" | "regex";
value: string;
}
export interface QueryOptions {
limit?: number;
offset?: number;
}
export interface QueryInput {
filters: QueryFilter[];
options?: QueryOptions;
}

View File

@@ -0,0 +1,40 @@
import { YonexusError } from '../models/errors';
import type { Action, Actor, Scope } from "../models/types";
import { JsonStore } from "../store/jsonStore";
function hasRole(store: JsonStore, actor: Actor, role: string): boolean {
const me = store.findAgent(actor.agentId);
return Boolean(me?.roles.includes(role as never));
}
function inDeptScope(scope: Scope): boolean {
return Boolean(scope.deptId);
}
function inTeamScope(scope: Scope): boolean {
return Boolean(scope.teamId);
}
export function authorize(action: Action, actor: Actor, scope: Scope, store: JsonStore): void {
const orgAdmin = hasRole(store, actor, "org_admin");
const deptAdmin = hasRole(store, actor, "dept_admin") && inDeptScope(scope);
const teamLead = hasRole(store, actor, "team_lead") && inTeamScope(scope);
const agent = hasRole(store, actor, "agent");
const allowed =
(action === "create_organization" && orgAdmin) ||
(action === "create_department" && orgAdmin) ||
(action === "create_team" && (orgAdmin || deptAdmin)) ||
(action === "assign_identity" && (orgAdmin || deptAdmin || teamLead)) ||
(action === "register_agent" && (orgAdmin || deptAdmin || teamLead)) ||
(action === "set_supervisor" && (orgAdmin || deptAdmin)) ||
(action === "query_agents" && (orgAdmin || deptAdmin || teamLead || agent));
if (!allowed) {
throw new YonexusError('PERMISSION_DENIED', `permission_denied: ${action}`, {
action,
actorId: actor.agentId,
scope
});
}
}

View File

@@ -0,0 +1,19 @@
import type { AuditLogEntry } from '../models/audit';
const MAX_AUDIT = 1000;
export class AuditStore {
private logs: AuditLogEntry[] = [];
append(entry: AuditLogEntry): AuditLogEntry {
this.logs.push(entry);
if (this.logs.length > MAX_AUDIT) this.logs.shift();
return entry;
}
list(limit = 100, offset = 0): AuditLogEntry[] {
const safeLimit = Math.min(Math.max(1, limit), 500);
const safeOffset = Math.max(0, offset);
return this.logs.slice(safeOffset, safeOffset + safeLimit);
}
}

View File

@@ -0,0 +1,156 @@
import { DEFAULT_STATE } from "../config/defaults";
import type {
Agent,
Department,
Identity,
Organization,
StoreState,
Supervisor,
Team
} from "../models/types";
import { readJsonFile, writeJsonFile } from "../utils/fs";
export class JsonStore {
private state: StoreState;
constructor(private readonly filePath: string) {
this.state = readJsonFile<StoreState>(filePath, DEFAULT_STATE);
}
save(): void {
writeJsonFile(this.filePath, this.state);
}
snapshot(): StoreState {
return JSON.parse(JSON.stringify(this.state)) as StoreState;
}
replace(state: StoreState): void {
this.state = JSON.parse(JSON.stringify(state)) as StoreState;
this.save();
}
addOrganization(org: Organization): Organization {
this.state.organizations.push(org);
this.save();
return org;
}
addDepartment(dept: Department): Department {
this.state.departments.push(dept);
this.save();
return dept;
}
renameDepartment(deptId: string, name: string): Department | undefined {
const dept = this.findDepartment(deptId);
if (!dept) return undefined;
dept.name = name;
this.save();
return dept;
}
deleteDepartment(deptId: string): boolean {
const before = this.state.departments.length;
this.state.departments = this.state.departments.filter((d) => d.id !== deptId);
this.state.teams = this.state.teams.filter((t) => t.deptId !== deptId);
this.state.identities = this.state.identities.filter((i) => i.deptId !== deptId);
const changed = this.state.departments.length !== before;
if (changed) this.save();
return changed;
}
addTeam(team: Team): Team {
this.state.teams.push(team);
this.save();
return team;
}
renameTeam(teamId: string, name: string): Team | undefined {
const team = this.findTeam(teamId);
if (!team) return undefined;
team.name = name;
this.save();
return team;
}
migrateTeam(teamId: string, newDeptId: string): Team | undefined {
const team = this.findTeam(teamId);
if (!team) return undefined;
team.deptId = newDeptId;
for (const identity of this.state.identities) {
if (identity.teamId === teamId) identity.deptId = newDeptId;
}
this.save();
return team;
}
deleteTeam(teamId: string): boolean {
const before = this.state.teams.length;
this.state.teams = this.state.teams.filter((t) => t.id !== teamId);
this.state.identities = this.state.identities.filter((i) => i.teamId !== teamId);
const changed = before !== this.state.teams.length;
if (changed) this.save();
return changed;
}
addAgent(agent: Agent): Agent {
this.state.agents.push(agent);
this.save();
return agent;
}
addIdentity(identity: Identity): Identity {
this.state.identities.push(identity);
this.save();
return identity;
}
upsertSupervisor(rel: Supervisor): Supervisor {
const idx = this.state.supervisors.findIndex((x) => x.agentId === rel.agentId);
if (idx >= 0) this.state.supervisors[idx] = rel;
else this.state.supervisors.push(rel);
this.save();
return rel;
}
findAgent(agentId: string): Agent | undefined {
return this.state.agents.find((a) => a.id === agentId);
}
findOrganization(orgId: string): Organization | undefined {
return this.state.organizations.find((o) => o.id === orgId);
}
listOrganizations(): Organization[] {
return this.state.organizations;
}
findDepartment(deptId: string): Department | undefined {
return this.state.departments.find((d) => d.id === deptId);
}
listDepartments(): Department[] {
return this.state.departments;
}
findTeam(teamId: string): Team | undefined {
return this.state.teams.find((t) => t.id === teamId);
}
listTeams(): Team[] {
return this.state.teams;
}
listAgents(): Agent[] {
return this.state.agents;
}
listIdentities(): Identity[] {
return this.state.identities;
}
findSupervisor(agentId: string): Supervisor | undefined {
return this.state.supervisors.find((s) => s.agentId === agentId);
}
}

18
plugin/core/utils/fs.ts Normal file
View File

@@ -0,0 +1,18 @@
import fs from "node:fs";
import path from "node:path";
export function ensureDirForFile(filePath: string): void {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
}
export function readJsonFile<T>(filePath: string, fallback: T): T {
if (!fs.existsSync(filePath)) return fallback;
const raw = fs.readFileSync(filePath, "utf8");
return JSON.parse(raw) as T;
}
export function writeJsonFile(filePath: string, data: unknown): void {
ensureDirForFile(filePath);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
}

10
plugin/core/utils/id.ts Normal file
View File

@@ -0,0 +1,10 @@
const SAFE = /[^a-z0-9]+/g;
export function slug(input: string): string {
return input.trim().toLowerCase().replace(SAFE, "-").replace(/^-+|-+$/g, "");
}
export function makeId(prefix: string, input: string): string {
const s = slug(input);
return `${prefix}:${s || "unknown"}`;
}

245
plugin/core/yonexus.ts Normal file
View File

@@ -0,0 +1,245 @@
import path from "node:path";
import { DEFAULT_SCHEMA } from "./config/defaults";
import type { AuditLogEntry } from "./models/audit";
import { YonexusError } from "./models/errors";
import type {
Actor,
Agent,
DocsScope,
DocsTopic,
Identity,
QueryInput,
Scope,
StoreState,
YonexusSchema
} from "./models/types";
import { authorize } from "./permissions/authorize";
import { AuditStore } from "./store/auditStore";
import { JsonStore } from "./store/jsonStore";
import { queryIdentities } from "../tools/query";
import { ResourceLayout } from "../tools/resources";
import { makeId } from "./utils/id";
export interface YonexusOptions {
dataFile?: string;
schema?: YonexusSchema;
registrars?: string[];
openclawDir?: string;
}
export class Yonexus {
private readonly schema: YonexusSchema;
private readonly registrars: Set<string>;
private readonly store: JsonStore;
private readonly audit = new AuditStore();
private readonly resources: ResourceLayout;
constructor(options: YonexusOptions = {}) {
const dataFile = options.dataFile ?? path.resolve(process.cwd(), "data/org.json");
this.store = new JsonStore(dataFile);
this.schema = options.schema ?? DEFAULT_SCHEMA;
this.registrars = new Set(options.registrars ?? []);
const openclawDir =
options.openclawDir ??
process.env.OPENCLAW_DIR ??
path.resolve(process.env.HOME ?? process.cwd(), ".openclaw");
this.resources = new ResourceLayout(path.join(openclawDir, "yonexus"));
}
private log(entry: Omit<AuditLogEntry, "id" | "ts">): void {
this.audit.append({
id: makeId("audit", `${entry.actorId}-${entry.action}-${Date.now()}`),
ts: new Date().toISOString(),
...entry
});
}
createOrganization(actor: Actor, name: string) {
authorize("create_organization", actor, {}, this.store);
const orgId = makeId("org", name);
if (this.store.findOrganization(orgId)) {
throw new YonexusError("ALREADY_EXISTS", `organization_exists: ${orgId}`);
}
const org = this.store.addOrganization({ id: orgId, name });
this.resources.ensureOrganization(name);
this.log({ actorId: actor.agentId, action: "create_organization", target: org.id, status: "ok" });
return org;
}
createDepartment(actor: Actor, name: string, orgId: string) {
try {
authorize("create_department", actor, { orgId }, this.store);
if (!this.store.findOrganization(orgId)) {
throw new YonexusError("NOT_FOUND", `organization_not_found: ${orgId}`, { orgId });
}
const dept = { id: makeId("dept", name), name, orgId };
const result = this.store.addDepartment(dept);
this.log({ actorId: actor.agentId, action: "create_department", target: result.id, status: "ok" });
return result;
} catch (error) {
this.log({
actorId: actor.agentId,
action: "create_department",
target: name,
status: "error",
message: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
createTeam(actor: Actor, name: string, deptId: string) {
authorize("create_team", actor, { deptId }, this.store);
const dept = this.store.findDepartment(deptId);
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`, { deptId });
const org = this.store.findOrganization(dept.orgId);
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
const team = { id: makeId("team", `${deptId}-${name}`), name, deptId };
const result = this.store.addTeam(team);
this.resources.ensureTeam(org.name, name);
this.log({ actorId: actor.agentId, action: "create_team", target: result.id, status: "ok" });
return result;
}
registerAgent(actor: Actor, agentId: string, name: string, roles: Agent["roles"] = ["agent"]) {
if (this.registrars.size > 0 && !this.registrars.has(actor.agentId)) {
throw new YonexusError("REGISTRAR_DENIED", `registrar_denied: ${actor.agentId}`);
}
const isBootstrap = this.store.listAgents().length === 0 && actor.agentId === agentId;
if (!isBootstrap) authorize("register_agent", actor, {}, this.store);
if (this.store.findAgent(agentId)) {
throw new YonexusError("ALREADY_EXISTS", `agent_exists: ${agentId}`, { agentId });
}
const result = this.store.addAgent({ id: agentId, name, roles });
this.log({ actorId: actor.agentId, action: "register_agent", target: result.id, status: "ok" });
return result;
}
assignIdentity(actor: Actor, agentId: string, deptId: string, teamId: string, meta: Record<string, string>): Identity {
authorize("assign_identity", actor, { deptId, teamId }, this.store);
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
const dept = this.store.findDepartment(deptId);
if (!dept) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
const team = this.store.findTeam(teamId);
if (!team) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
const org = this.store.findOrganization(dept.orgId);
if (!org) throw new YonexusError("NOT_FOUND", `organization_not_found: ${dept.orgId}`);
const validatedMeta: Record<string, string> = {};
for (const [field, value] of Object.entries(meta)) {
if (!this.schema[field]) continue;
validatedMeta[field] = String(value);
}
const result = this.store.addIdentity({
id: makeId("identity", `${agentId}-${deptId}-${teamId}-${Date.now()}`),
agentId,
deptId,
teamId,
meta: validatedMeta
});
this.resources.ensureAgent(org.name, team.name, agentId);
this.log({ actorId: actor.agentId, action: "assign_identity", target: result.id, status: "ok" });
return result;
}
setSupervisor(actor: Actor, agentId: string, supervisorId: string, deptId?: string) {
authorize("set_supervisor", actor, { deptId }, this.store);
if (!this.store.findAgent(agentId)) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
if (!this.store.findAgent(supervisorId)) throw new YonexusError("NOT_FOUND", `supervisor_not_found: ${supervisorId}`);
if (agentId === supervisorId) throw new YonexusError("INVALID_SUPERVISOR", "invalid_supervisor: self_reference");
const result = this.store.upsertSupervisor({ agentId, supervisorId });
this.log({ actorId: actor.agentId, action: "set_supervisor", target: `${agentId}->${supervisorId}`, status: "ok" });
return result;
}
whoami(agentId: string) {
const agent = this.store.findAgent(agentId);
if (!agent) throw new YonexusError("NOT_FOUND", `agent_not_found: ${agentId}`);
const identities = this.store.listIdentities().filter((x) => x.agentId === agentId);
const supervisor = this.store.findSupervisor(agentId);
return { agent, identities, supervisor };
}
queryAgents(actor: Actor, scope: Scope, query: QueryInput) {
authorize("query_agents", actor, scope, this.store);
const identities = this.store.listIdentities();
return queryIdentities(identities, query, this.schema);
}
getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string {
return this.resources.getDocs(scope, topic, keyword);
}
renameDepartment(actor: Actor, deptId: string, newName: string) {
authorize("create_department", actor, {}, this.store);
const updated = this.store.renameDepartment(deptId, newName);
if (!updated) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
this.log({ actorId: actor.agentId, action: "rename_department", target: deptId, status: "ok" });
return updated;
}
renameTeam(actor: Actor, teamId: string, newName: string, deptId?: string) {
authorize("create_team", actor, { deptId }, this.store);
const updated = this.store.renameTeam(teamId, newName);
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
this.log({ actorId: actor.agentId, action: "rename_team", target: teamId, status: "ok" });
return updated;
}
migrateTeam(actor: Actor, teamId: string, newDeptId: string) {
authorize("create_team", actor, { deptId: newDeptId }, this.store);
if (!this.store.findDepartment(newDeptId)) throw new YonexusError("NOT_FOUND", `department_not_found: ${newDeptId}`);
const updated = this.store.migrateTeam(teamId, newDeptId);
if (!updated) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
this.log({ actorId: actor.agentId, action: "migrate_team", target: `${teamId}->${newDeptId}`, status: "ok" });
return updated;
}
deleteDepartment(actor: Actor, deptId: string) {
authorize("create_department", actor, {}, this.store);
const ok = this.store.deleteDepartment(deptId);
if (!ok) throw new YonexusError("NOT_FOUND", `department_not_found: ${deptId}`);
this.log({ actorId: actor.agentId, action: "delete_department", target: deptId, status: "ok" });
return { ok };
}
deleteTeam(actor: Actor, teamId: string, deptId?: string) {
authorize("create_team", actor, { deptId }, this.store);
const ok = this.store.deleteTeam(teamId);
if (!ok) throw new YonexusError("NOT_FOUND", `team_not_found: ${teamId}`);
this.log({ actorId: actor.agentId, action: "delete_team", target: teamId, status: "ok" });
return { ok };
}
exportData(actor: Actor): StoreState {
authorize("query_agents", actor, {}, this.store);
this.log({ actorId: actor.agentId, action: "export_data", status: "ok" });
return this.store.snapshot();
}
importData(actor: Actor, state: StoreState): { ok: true } {
authorize("create_department", actor, {}, this.store);
this.store.replace(state);
this.log({ actorId: actor.agentId, action: "import_data", status: "ok" });
return { ok: true };
}
listAuditLogs(limit = 100, offset = 0): AuditLogEntry[] {
return this.audit.list(limit, offset);
}
debugSnapshot(): StoreState {
return this.store.snapshot();
}
}

0
plugin/hooks/.gitkeep Normal file
View File

45
plugin/index.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Yonexus Plugin Entry
*
* This file is the wiring layer: it initializes and re-exports the core
* Yonexus class along with models, tools, and memory adapters.
* No business logic lives here.
*/
// ── Core ────────────────────────────────────────────────────────────────
export { Yonexus, type YonexusOptions } from "./core/yonexus";
// ── Models ──────────────────────────────────────────────────────────────
export { YonexusError } from "./core/models/errors";
export type { ErrorCode } from "./core/models/errors";
export type { AuditLogEntry } from "./core/models/audit";
export type {
Action,
Actor,
Agent,
Department,
DocsScope,
DocsTopic,
Identity,
Organization,
QueryFilter,
QueryInput,
QueryOptions,
Role,
SchemaField,
Scope,
StoreState,
Supervisor,
Team,
YonexusSchema,
} from "./core/models/types";
// ── Tools ───────────────────────────────────────────────────────────────
export { queryIdentities } from "./tools/query";
export { ResourceLayout } from "./tools/resources";
// ── Memory ──────────────────────────────────────────────────────────────
export { ScopeMemory, type MemoryPort } from "./core/memory/scopeMemory";
// ── Default export ──────────────────────────────────────────────────────
export { Yonexus as default } from "./core/yonexus";

View File

@@ -0,0 +1,13 @@
{
"id": "yonexus",
"name": "Yonexus",
"version": "0.2.0",
"description": "Organization hierarchy, agent identity management, query DSL, and scope memory for OpenClaw",
"configSchema": {
"type": "object",
"additionalProperties": true,
"properties": {
"enabled": { "type": "boolean", "default": true }
}
}
}

75
plugin/tools/query.ts Normal file
View File

@@ -0,0 +1,75 @@
import { YonexusError } from '../core/models/errors';
import type { Identity, QueryFilter, QueryInput, QueryOptions, YonexusSchema } from "../core/models/types";
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
const regexCache = new Map<string, RegExp>();
const containsCache = new Map<string, string>();
function getRegex(pattern: string): RegExp {
const cached = regexCache.get(pattern);
if (cached) return cached;
const created = new RegExp(pattern);
regexCache.set(pattern, created);
return created;
}
function normalizeNeedle(value: string): string {
const cached = containsCache.get(value);
if (cached) return cached;
const normalized = value.toLowerCase();
containsCache.set(value, normalized);
return normalized;
}
function isQueryable(field: string, schema: YonexusSchema): boolean {
return Boolean(schema[field]?.queryable);
}
function matchFilter(identity: Identity, filter: QueryFilter): boolean {
const raw = identity.meta[filter.field] ?? "";
switch (filter.op) {
case "eq":
return raw === filter.value;
case "contains":
return raw.toLowerCase().includes(normalizeNeedle(filter.value));
case "regex": {
const re = getRegex(filter.value);
return re.test(raw);
}
default:
return false;
}
}
function normalizeOptions(options?: QueryOptions): Required<QueryOptions> {
const limit = Math.min(Math.max(1, options?.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
const offset = Math.max(0, options?.offset ?? 0);
return { limit, offset };
}
function sortFilters(filters: QueryFilter[]): QueryFilter[] {
const weight = (f: QueryFilter): number => {
if (f.op === 'eq') return 1;
if (f.op === 'contains') return 2;
return 3;
};
return [...filters].sort((a, b) => weight(a) - weight(b));
}
export function queryIdentities(identities: Identity[], input: QueryInput, schema: YonexusSchema): Identity[] {
for (const filter of input.filters) {
if (!isQueryable(filter.field, schema)) {
throw new YonexusError('FIELD_NOT_QUERYABLE', `field_not_queryable: ${filter.field}`, {
field: filter.field
});
}
}
const orderedFilters = sortFilters(input.filters);
const filtered = identities.filter((identity) => orderedFilters.every((f) => matchFilter(identity, f)));
const { limit, offset } = normalizeOptions(input.options);
return filtered.slice(offset, offset + limit);
}

111
plugin/tools/resources.ts Normal file
View File

@@ -0,0 +1,111 @@
import fs from 'node:fs';
import path from 'node:path';
import { YonexusError } from '../core/models/errors';
import type { DocsScope, DocsTopic } from '../core/models/types';
import { slug } from '../core/utils/id';
const TOPICS: DocsTopic[] = ['docs', 'notes', 'knowledge', 'rules', 'lessons', 'workflows'];
function ensureDirs(base: string, dirs: string[]): void {
for (const d of dirs) fs.mkdirSync(path.join(base, d), { recursive: true });
}
export class ResourceLayout {
constructor(private readonly rootDir: string) {}
get organizationsRoot(): string {
return path.join(this.rootDir, 'organizations');
}
orgPath(orgName: string): string {
return path.join(this.organizationsRoot, slug(orgName));
}
teamPath(orgName: string, teamName: string): string {
return path.join(this.orgPath(orgName), 'teams', slug(teamName));
}
agentPath(orgName: string, teamName: string, agentId: string): string {
return path.join(this.teamPath(orgName, teamName), 'agents', slug(agentId));
}
ensureOrganization(orgName: string): void {
const root = this.orgPath(orgName);
ensureDirs(root, ['teams', ...TOPICS]);
}
ensureTeam(orgName: string, teamName: string): void {
const root = this.teamPath(orgName, teamName);
ensureDirs(root, ['agents', ...TOPICS]);
}
ensureAgent(orgName: string, teamName: string, agentId: string): void {
const root = this.agentPath(orgName, teamName, agentId);
ensureDirs(root, TOPICS);
}
private readTopicFiles(topicRoot: string, keyword: string): string[] {
if (!fs.existsSync(topicRoot)) return [];
let re: RegExp;
try {
re = new RegExp(keyword);
} catch {
throw new YonexusError('VALIDATION_ERROR', 'invalid_regex', { keyword });
}
const entries = fs.readdirSync(topicRoot, { withFileTypes: true });
return entries
.filter((e) => e.isFile() && re.test(e.name))
.map((e) => path.join(topicRoot, e.name));
}
getDocs(scope: DocsScope, topic: DocsTopic, keyword: string): string {
const groups: Record<'ORG' | 'DEPT' | 'TEAM' | 'AGENT', string[]> = {
ORG: [],
DEPT: [],
TEAM: [],
AGENT: []
};
const orgsRoot = this.organizationsRoot;
if (!fs.existsSync(orgsRoot)) {
return '----ORG\n----DEPT\n----TEAM\n----AGENT';
}
const orgs = fs.readdirSync(orgsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
for (const orgName of orgs) {
const orgPath = path.join(orgsRoot, orgName);
if (scope === 'organization') {
groups.ORG.push(...this.readTopicFiles(path.join(orgPath, topic), keyword));
}
const teamsRoot = path.join(orgPath, 'teams');
if (!fs.existsSync(teamsRoot)) continue;
const teams = fs.readdirSync(teamsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
for (const teamName of teams) {
const teamPath = path.join(teamsRoot, teamName);
if (scope === 'team') {
groups.TEAM.push(...this.readTopicFiles(path.join(teamPath, topic), keyword));
}
const agentsRoot = path.join(teamPath, 'agents');
if (!fs.existsSync(agentsRoot)) continue;
const agents = fs.readdirSync(agentsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
for (const agentId of agents) {
if (scope === 'agent') {
groups.AGENT.push(...this.readTopicFiles(path.join(agentsRoot, agentId, topic), keyword));
}
}
}
}
// department folders are not defined in this layout; reserved empty group for compatible output.
const printGroup = (k: 'ORG' | 'DEPT' | 'TEAM' | 'AGENT'): string =>
[`----${k}`, ...groups[k]].join('\n');
return [printGroup('ORG'), printGroup('DEPT'), printGroup('TEAM'), printGroup('AGENT')].join('\n');
}
}

34
scripts/demo.ts Normal file
View File

@@ -0,0 +1,34 @@
import path from 'node:path';
import fs from 'node:fs';
import { Yonexus } from '../plugin/index';
const dataFile = path.resolve(process.cwd(), 'data/demo-org.json');
if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile);
const yx = new Yonexus({ dataFile, registrars: ['orion'] });
yx.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']);
yx.registerAgent({ agentId: 'orion' }, 'hangman', 'Hangman', ['agent']);
const org = yx.createOrganization({ agentId: 'orion' }, 'Yonexus');
const dept = yx.createDepartment({ agentId: 'orion' }, 'Platform', org.id);
const team = yx.createTeam({ agentId: 'orion' }, 'Core', dept.id);
yx.assignIdentity({ agentId: 'orion' }, 'orion', dept.id, team.id, {
position: 'assistant',
discord_user_id: '1474088632750047324',
git_user_name: 'orion'
});
yx.setSupervisor({ agentId: 'orion' }, 'orion', 'hangman', dept.id);
const query = yx.queryAgents(
{ agentId: 'orion' },
{ deptId: dept.id },
{
filters: [{ field: 'git_user_name', op: 'eq', value: 'orion' }],
options: { limit: 10, offset: 0 }
}
);
console.log(JSON.stringify({ dept, team, query, audit: yx.listAuditLogs(20, 0) }, null, 2));

0
skills/.gitkeep Normal file
View File

54
tests/smoke.ts Normal file
View File

@@ -0,0 +1,54 @@
import assert from 'node:assert';
import path from 'node:path';
import fs from 'node:fs';
import { Yonexus } from '../plugin/index';
import { YonexusError } from '../plugin/core/models/errors';
const root = path.resolve(process.cwd(), 'data/test-openclaw');
const dataFile = path.resolve(process.cwd(), 'data/test-org.json');
if (fs.existsSync(dataFile)) fs.unlinkSync(dataFile);
if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
const app = new Yonexus({ dataFile, registrars: ['orion'], openclawDir: root });
app.registerAgent({ agentId: 'orion' }, 'orion', 'Orion', ['org_admin', 'agent']);
app.registerAgent({ agentId: 'orion' }, 'u1', 'U1', ['agent']);
const org = app.createOrganization({ agentId: 'orion' }, 'Yonexus');
const dept = app.createDepartment({ agentId: 'orion' }, 'Eng', org.id);
const team = app.createTeam({ agentId: 'orion' }, 'API', dept.id);
app.assignIdentity({ agentId: 'orion' }, 'u1', dept.id, team.id, {
git_user_name: 'u1',
position: 'dev'
});
const result = app.queryAgents(
{ agentId: 'orion' },
{ deptId: dept.id },
{ filters: [{ field: 'git_user_name', op: 'eq', value: 'u1' }] }
);
assert.equal(result.length, 1);
const expectedAgentDocsDir = path.join(root, 'yonexus', 'organizations', 'yonexus', 'teams', 'api', 'agents', 'u1', 'docs');
assert.equal(fs.existsSync(expectedAgentDocsDir), true);
let thrown = false;
try {
app.queryAgents(
{ agentId: 'orion' },
{ deptId: dept.id },
{ filters: [{ field: 'team', op: 'eq', value: 'API' }] }
);
} catch (e) {
thrown = e instanceof YonexusError && e.code === 'FIELD_NOT_QUERYABLE';
}
assert.equal(thrown, true);
let invalidRegexThrown = false;
try {
app.getDocs('agent', 'docs', '[');
} catch (e) {
invalidRegexThrown = e instanceof YonexusError && e.code === 'VALIDATION_ERROR';
}
assert.equal(invalidRegexThrown, true);
console.log('smoke test passed');

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist/yonexus",
"rootDir": "plugin",
"declaration": true,
"types": ["node"]
},
"include": ["plugin/**/*.ts"]
}