Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Summary

## Verification

- [ ] `node scripts/release-gate.mjs`
- [ ] No real image generation was triggered.
- [ ] Runtime-facing claims include tests, E2E evidence, or readback evidence.

## Architecture Boundary

- [ ] This change keeps OpenClaw service-thin: no local classifier, manifest scan, profile DB read, generation call, direct Discord REST, or delivery orchestration.
- [ ] If this changes service-owned classifier/profile/channel/asset/delivery behavior, an owner-approved architecture decision is linked.
- [ ] If this changes emotion contract behavior, `tests/fixtures/emotion-contract-v1.json` and all parity tests are updated.
15 changes: 11 additions & 4 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,14 @@ jobs:
fetch-depth: 0

- name: Check PR size
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
ADDITIONS=$(git diff --numstat origin/main...HEAD | awk '{sum+=$1} END {print sum+0}')
DELETIONS=$(git diff --numstat origin/main...HEAD | awk '{sum+=$2} END {print sum+0}')
set -euo pipefail

git cat-file -e "$BASE_SHA^{commit}"
ADDITIONS=$(git diff --numstat "$BASE_SHA"...HEAD | awk '{sum+=$1} END {print sum+0}')
DELETIONS=$(git diff --numstat "$BASE_SHA"...HEAD | awk '{sum+=$2} END {print sum+0}')
TOTAL=$((ADDITIONS + DELETIONS))
echo "📊 PR Size: +$ADDITIONS / -$DELETIONS (total: $TOTAL lines)"

Expand Down Expand Up @@ -65,10 +70,12 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OWNER_REVIEWERS: changeroa
OWNER_REVIEW_LABEL: owner-reviewed
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail

CHANGED=$(git diff --name-only origin/main...HEAD)
git cat-file -e "$BASE_SHA^{commit}"
CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD)
OWNER_GATE_REQUIRED=0
HEAD_SHA="${{ github.event.pull_request.head.sha }}"

Expand Down Expand Up @@ -122,7 +129,7 @@ jobs:
fi

# Check if assets were removed
REMOVED=$(git diff --name-only --diff-filter=D origin/main...HEAD | grep "^assets/" || true)
REMOVED=$(git diff --name-only --diff-filter=D "$BASE_SHA"...HEAD | grep "^assets/" || true)
if [ -n "$REMOVED" ]; then
echo "::error::Asset files removed. This may break emotion image display:"
echo "$REMOVED"
Expand Down
45 changes: 45 additions & 0 deletions docs/adr/ADR-0001-discord-ambient-worker-topology.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR-0001: Separate Discord ambient worker from the HTTP API

- Status: accepted
- Date: 2026-07-24
- Owner approval: recorded in the approved `adaptive-ambient-discord-participant` plan and draft

## Decision

`service/src/main.ts` is the API-only entrypoint. It opens the Hent-ai HTTP service and never imports or starts a Discord participant worker. `service/src/discord-ambient-worker.ts` is the only participant entrypoint. The two processes share the service SQLite WAL database.

The worker is opt-in and fail-closed. `HENT_AI_DISCORD_PARTICIPANT_ENABLED=true` and a strict startup-only `HENT_AI_DISCORD_PARTICIPANT_ALLOWLIST` are required. The allowlist is comma-separated `guildId:channelId` Snowflake pairs. Each pair must also have an enabled service channel mapping; a mapped profile must exist when one is selected. Persona precedence is channel profile `soulSnippet`, then `HENT_AI_CONVERSATION_PERSONA`, then the generic service persona. Missing mappings are skipped. If no eligible scope remains, the worker opens no Discord connection.

The worker also requires `HENT_AI_SERVICE_DB_PATH`, `HENT_AI_DISCORD_BOT_TOKEN`, and OpenAI-compatible appraisal provider endpoint, token, and model. `ServiceDatabase` at that path is the service runtime profile/channel SSOT; `ProfileDatabase` remains a legacy/generation migration concern, not the participant runtime database. It has no environment Discord API-base override. Production uses Discord API v10; loopback base URLs are constructor-only test seams. Logs are structured and never include tokens.

Each allowed guild/channel gets its own lease key, so one worker process can own multiple scopes without a channel releasing another channel's fence. Scope leases and claimed work both last 30 seconds and renew every 10 seconds with their original fence token. The runtime rechecks the current enabled mapping, abort signal, fence, and work claim after roster load, immediately before provider dispatch, and before outcome mutation. The archive owner has a separate lease: an archive-only owner may call the configured provider without any participant scope lease, but only for an exact startup-allowlisted Discord scope whose current service mapping is enabled; it makes zero Discord API calls. This approved topology does not mean zero all network. Provider calls remain outside transactions. Shutdown aborts the shared controller first, cancels heartbeats and poll timers, stops archive owners and cores, then waits active work, releases matching fences, and closes SQLite.

## Retention and autonomy

Guild/user relationships are bounded and idempotent. Membership v1 uses complete paged guild rosters; active humans are recent (10-minute) non-bot authors intersected with a fresh complete roster. First polling seeds a cursor without replying; later work is durable. Raw events are marked archived after 14 days, while raw events and source-linked summaries remain permanently: neither is deleted.

Ambient participation is continuous and probabilistic. A normal request for silence is social transcript evidence: the model may accept, ignore, resist, or escalate. It must never become deterministic mute, quit, or quiet-until state. Only operational kill switches, lease loss, disabled mappings, and invalid startup configuration are deterministic. Delivery uses one to five typed bubbles, bounded length delay, durable nonces/receipts, and cancels remaining bubbles on newer human ingress.

## Ambient hardening addendum

Typing is used only for the documented short-processing exception immediately before delivery; the worker must never sustain Discord typing for more than 10 seconds.

Each valid appraisal first relaxes stored drive toward the 0.5 baseline after idle time. Silence requests add bounded, decaying social pressure, which scales desired drive but never deterministically mutes an explicit mention. Consecutive missed valid speak opportunities increase the effective probability with `p_eff = 1 - (1 - p)^(1 + skipStreak)`; a long speech streak halves the probability after its soft cap. Both mechanisms remain per-event probabilistic draws.

Per-channel overrides live in `channel_settings.settings_json`; invalid or absent values fall back independently:

| Key | Valid value | Default |
| --- | --- | --- |
| `ambientBudgetPerHour` | integer > 0 | `20` |
| `ambientConfidenceFloor` | number in `[0,1]` | `0.7` |
| `ambientIdleDecayTauMs` | integer >= `60000` | `7200000` (2h) |
| `ambientPressureTauMs` | integer >= `60000` | `1800000` (30m) |
| `ambientPityEnabled` | boolean | `true` |

## Live QA

The bot-token QA guild/channel pair is a fixture and documentation-only live-QA target, not production configuration. Local loopback wire tests are the proof of human Discord ingress. Conditional live QA validates bot egress using synthetic durable work in a temporary DB and cleans up created bot messages.

## Consequences

Operators start exactly one role per process. The former `server-with-poller.ts` helper is legacy watcher-poller composition and is not an API or participant-worker entrypoint.
53 changes: 51 additions & 2 deletions docs/agent-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,19 @@ Equivalent package script:
npm run release:check
```

The gate runs the focused service verifier/worker regression tests and the full OpenClaw suite:
The gate runs the service-owned boundary check, focused service verifier/poller/worker regression tests, adaptive ambient client/worker/runtime/delivery/archive/roster/wire/live regressions, shared emotion contract tests, generate manifest tests, Hermes compatibility tests, and the full OpenClaw suite:

```bash
cd service && npx vitest run src/service.test.ts src/verifier.test.ts src/generation-worker.test.ts
node scripts/service-owned-boundary-check.mjs
cd service && npx vitest run src/service.test.ts src/verifier.test.ts src/discord-rest-poller.test.ts src/generation-worker.test.ts src/final-response-media-sanitizer.test.ts
cd service && npx vitest run src/adaptive-ambient-contracts.test.ts src/adaptive-ambient-provider.test.ts src/adaptive-ambient-runtime.test.ts src/adaptive-ambient-store.test.ts src/conversation-archive-scheduler.test.ts src/conversation-relationship-profile.test.ts src/discord-participant-client.test.ts src/discord-ambient-worker-core.test.ts src/discord-ambient-delivery.test.ts src/discord-ambient-worker.test.ts src/discord-ambient-worker.wire.test.ts src/discord-ambient-worker.live.test.ts src/adaptive-ambient-review-regressions.test.ts src/adaptive-ambient.redteam.test.ts src/conversation-ambient.test.ts src/discord-ambient-worker.redteam.test.ts
cd shared && npx vitest run
cd generate && npx vitest run src/sets.test.ts
python3 -m unittest discover -s tests/hermes
cd openclaw && npx vitest run
cd openclaw && npx tsc --noEmit
cd service && npx tsc --noEmit
cd generate && npx tsc --noEmit
```

Any failing command blocks the release. CI required-check enforcement is intentionally deferred; this gate is the local/manual release checklist for this slice.
Expand Down Expand Up @@ -80,6 +88,10 @@ Provider result shape for generated image persistence:

When `assetRoot` is supplied, the worker writes the image under `generated/<assetSetId>/<emotion>/<jobId>-<filename>`, upserts `storage_objects` and `assets`, strips inline base64 from the stored job result, and exposes the image through `/static/...`. Tests must keep providers mocked; do not trigger paid image generation in CI.

Persisted generated assets include provenance metadata on both `storage_objects` and `assets`: job id, content hash, content type, byte size, dimensions when known, source references, source (`hent-ai-generation-worker`), verification status, and hashes of request/provider metadata. The metadata intentionally avoids persisting raw prompts, conversation windows, or provider payloads with the long-lived storage object.

Generated asset writes are treated as immutable. A worker must fail rather than overwrite an existing generated storage key or asset id; active-set changes are pointer updates, not root-file copy operations.

For the community-cron workflow, `POST /v1/assets/generate` also accepts a cron selector request:

```json
Expand All @@ -98,6 +110,43 @@ For the community-cron workflow, `POST /v1/assets/generate` also accepts a cron

The service also exposes `GET /v1/channels/cron-enabled`, which returns the service-owned cron allowlist plus a revision token so OpenClaw can decide when to refresh its cached channel set.

## Discord ambient worker

The participant runs independently from the HTTP API:

```bash
cd service
npm run start:api
npm run start:discord-ambient-worker
```

The worker is fail-closed. Set `HENT_AI_DISCORD_PARTICIPANT_ENABLED=true`, `HENT_AI_DISCORD_PARTICIPANT_ALLOWLIST` as comma-separated `guildId:channelId` Snowflake pairs, `HENT_AI_SERVICE_DB_PATH`, `HENT_AI_DISCORD_BOT_TOKEN`, `HENT_AI_CONVERSATION_PROVIDER_ENDPOINT`, `HENT_AI_CONVERSATION_PROVIDER_TOKEN`, and `HENT_AI_CONVERSATION_PROVIDER_MODEL`. Every allowlisted channel also needs an enabled service channel mapping. A selected profile must exist; its `soulSnippet` wins over `HENT_AI_CONVERSATION_PERSONA`, then the generic persona.

There is intentionally no Discord API-base environment setting. Production uses fixed Discord v10; only tests inject a loopback client base URL. The worker validates bot identity and guild/channel ownership only after acquiring a scope lease; with no acquired scope lease it remains archive-only standby and makes no Discord request. Its independent archive owner may still call the configured provider without a participant scope lease, but only for an exact startup-allowlisted Discord scope with a currently enabled DB mapping; it never calls the Discord API. That approved archive-only topology is not a blanket ban on all network. Archive compaction rechecks this boundary immediately before claim and provider dispatch, so raw legacy, disabled, or non-allowlisted scopes never reach the provider. Scope and claimed-work leases are 30 seconds with 10-second heartbeats; appraisal rechecks current mapping, abort, fence, and work claim after roster load and immediately before provider dispatch. Stop with `SIGINT` or `SIGTERM`; it aborts active work first, then cancels timers, stops archive/core ownership, waits the active boundary, releases matching leases, and closes SQLite. Roll back by stopping only the worker process or setting `HENT_AI_DISCORD_PARTICIPANT_ENABLED` to anything other than `true`; the API remains available and durable work is retained.

The conditional bot-token live-QA pair is guild `1483095221460799489` and channel `1498703634098294976`. It is QA-only and not production scope, default, or hard-coded configuration. Local loopback wire tests remain the sole proof of human ingress.

### Ambient tuning and calibration

Set per-channel overrides in `channel_settings.settings_json`; absent or invalid keys use these defaults:

| Key | Default |
| --- | --- |
| `ambientBudgetPerHour` | `20` |
| `ambientConfidenceFloor` | `0.7` |
| `ambientIdleDecayTauMs` | `7200000` (2h) |
| `ambientPressureTauMs` | `1800000` (30m) |
| `ambientPityEnabled` | `true` |

Run the deterministic domain calibration (no provider, network, or wait) after changing ambient decision behavior:

```bash
cd service
npx tsx scripts/replay-ambient-calibration.ts
```

The script exits nonzero when idle decay is not monotonic, pressure leaves `[0,1]`, or effective pity probability falls below its base probability.

## Deploy

Plugin is loaded by OpenClaw gateway from `plugins.load.paths` config. Current production-style setup should load this repository's `openclaw/` adapter and enable `plugins.entries.hent-ai-service-adapter` with the `hentAiService` connection config.
Expand Down
Loading
Loading