openvibers/openvibe.ai Commits

Capability proposals (ai.run.create, ai.run.read, ai.workflow.manage, ai.provider.manage, ai.usage.read) and the ai service manifest; README, .env.example, systemd unit, nginx vhost, CI on Node 22.22.1, STATUS alpha W13

OP OpenVibers committed 23 Sept 2026 · 87d38e314f · 13 files, +582 −52


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..1cb46df
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,95 @@
++# ═══════════════════════════════════════════════════════════════
++# ai.openvibe.network — OpenVibe.AI Environment Configuration
++# Copy to .env and fill in values. In production this lives at /etc/openvibe/ai.env (0600).
++# Secrets are read by NAME only (providers store 'env:NAME' references); nothing logs or returns them.
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4700
++HOST=127.0.0.1
++NODE_ENV=development
++BASE_URL=http://localhost:4700
++
++# SQLite (production unit: /var/lib/openvibe-ai/ai.db via StateDirectory=openvibe-ai)
++AI_DB_PATH=./data/ai.db
++
++# Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
++# The public key is fetched from <Network>/api/.well-known/jwks; OV_NETWORK_PUBLIC_KEY pins a PEM instead.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++#OV_NETWORK_ISSUER=https://openvibe.network
++#OV_NETWORK_PUBLIC_KEY=
++
++# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──
++# (ai_enabled, ai_provider, ai_base_url, ai_api_key, ai_model, ai_model_<role>, ai_pricing_json,
++# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day). scripts/import-from-live.js prints the
++# values Live had (never the key).
++#AI_ENABLED=true
++# anthropic | openai | openrouter | groq | ollama | (blank = OpenAI-shaped). Anthropic only when
++# AI_BASE_URL is blank or anthropic.com; any other base URL is treated as OpenAI-compatible.
++AI_PROVIDER=
++#AI_BASE_URL=https://api.openai.com/v1
++AI_API_KEY=
++# Blank = claude-sonnet-5 for anthropic, gpt-4o-mini otherwise (Live's defaults)
++AI_MODEL=
++# Per-role models for Live's workflows (routes live.chat / live.vision / live.director / live.summary)
++#AI_MODEL_CHAT=gpt-5-nano
++#AI_MODEL_VISION=gpt-5-mini
++#AI_MODEL_DIRECTOR=gpt-5-mini
++#AI_MODEL_SUMMARY=gpt-5-mini
++#AI_EMBEDDING_MODEL=text-embedding-3-small
++#AI_PROVIDER_TIMEOUT_MS=30000
++# USD per million tokens, longest model-prefix match: {"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005}}
++#AI_PRICING_JSON=
++#AI_INPUT_COST_PER_MTOK=3
++#AI_OUTPUT_COST_PER_MTOK=15
++
++# Optional second provider used as every route's fallback (OpenAI-compatible unless =anthropic)
++#AI_FALLBACK_PROVIDER=
++#AI_FALLBACK_BASE_URL=
++#AI_FALLBACK_API_KEY=
++#AI_FALLBACK_MODEL=
++# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})
++#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete
++# Let routes fall back to the deterministic stub when every real provider fails. Default: on outside
++# production, off in production (synthetic output must never stand in for a real answer there).
++#AI_STUB_FALLBACK=false
++
++# ── Local speech-to-text: whisper.cpp, the same names Live uses ──
++#WHISPER_BIN=/home/ubuntu/whisper.cpp/build/bin/whisper-cli
++#WHISPER_MODEL=/home/ubuntu/whisper.cpp/models/ggml-base.en.bin
++#WHISPER_MODEL_LIVE=
++#WHISPER_MODEL_MULTI=/home/ubuntu/whisper.cpp/models/ggml-small.bin
++#WHISPER_VAD_MODEL=
++#WHISPER_VAD=1
++#WHISPER_THREADS=4
++#WHISPER_BEAM=1
++#WHISPER_MAX_CONCURRENT=1
++
++# ── Quotas seeded at boot (admin API edits them afterwards; 0 = none) ──
++# Live's ai_max_cost_usd_per_day: one daily spend cap across every caller
++AI_MAX_COST_USD_PER_DAY=0
++# Requests per minute / per day for EACH calling service
++#AI_QUOTA_SERVICE_RPM=600
++#AI_QUOTA_SERVICE_RPD=50000
++
++# ── Runs, cache, logs ──
++#AI_MAX_WAIT_MS=60000
++#AI_MAX_CONCURRENT_RUNS=4
++#AI_CACHE_TTL_SEC=604800
++#AI_MAX_INPUT_BYTES=6291456
++#AI_RUN_RETENTION_DAYS=30
++# Raw prompt/response logging: opt-in debugging only, and only for runs whose caller sets options.debug
++#AI_DEBUG_RAW_LOG=false
++#AI_BREAKER_FAILURES=3
++#AI_BREAKER_COOLDOWN_MS=60000
++#AI_PROVIDER_RETRIES=1
++#AI_PROVIDER_RETRY_DELAY_MS=800
++
++# ── Media inputs: only these https hosts are fetched (plus the Media internal origin) ──
++OV_MEDIA_URL=https://openvibe.media
++#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
++#AI_FETCH_ALLOW_HOSTS=openvibe.media,openvibe.live,openvibe.network,*.openvibe.network,openvibe.community,openvibe.tools,openvibe.games
++#AI_FETCH_MAX_BYTES=536870912
++#AI_FETCH_MAX_IMAGE_BYTES=12582912
++#AI_FETCH_TIMEOUT_MS=120000
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..02b408d
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,22 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # production runs Node 22
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm test
++ # openvibe-contracts still carries the charter-only `ai` manifest (range ^0.1.0, no
++ # capabilities); the replacement is proposed in docs/capabilities-proposal/. Until a contracts
++ # release ships it this check reports that range, so it may not fail the build yet.
++ - name: contract check (openvibe-contracts)
++ continue-on-error: true
++ run: npx openvibe-contracts-check --service ai --src server
ddiff --git a/README.md b/README.md
iindex c3e881d..e569b6f 100644
---- a/README.md
++++ b/README.md
@@@ -2,57 +2,168 @@
> Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product.
--**Status:** placeholder — planning only, no runnable code yet.
--**Domain:** `ai.openvibe.network`
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §34.
++**Status:** alpha (roadmap Wave 13). The service runs and is tested; it is **not deployed**, and the
++domain keeps its placeholder page until the launch rule below is met.
++**Domain:** `ai.openvibe.network` · **Port:** 4700 · **Unit:** `openvibe-ai.service`
++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §15.14, §33, §34.
**License:** AGPL-3.0 (same as every OpenVibe service).
--## Purpose
--
--The reusable AI authority extracted from OpenVibe.Live. Products invoke registered, versioned workflows that produce draft/evidence packages; AI never owns publication truth.
--
--## Owns
--
--- providers, models, routing profiles, prompt templates, workflow definitions, runs/jobs, request/completion metadata logs, source documents/citations, cache, quotas/usage, audit (the 11 model groups)
--- deterministic no-key stub provider, local HTTP seam, bounded external adapters
--
--## Does not own
--
--- publication decisions (Wiki/Blog/News/… own their revisions)
--- source registry policy (shared Sources contract; adapters registered here)
--
--## Planned surfaces
--
--- status/admin, provider/model/route/template/workflow CRUD, run create/list/detail/cancel/retry
--- `chat|generate|summarize|classify|extract|enrich|embed` conveniences
--- seed workflows: `wiki.generate_*`, `blog.draft_post`, `news.summarize_story|compare_perspectives`, `reviews.summarize_entity`, `deals.enrich_deal`, `coupons.extract_coupon`, `trade.summarize_market_context`, `codes.generate_docs`, `games.generate_lore`, `moderation.classify`
--
--## Data (authority tables / families)
--
--- see above
--
--## Capabilities and events
--
--- `ai.run.create|get|cancel`, `ai.workflow.invoke`, `ai.template.*`
--
--Events: ``ai.run.queued|succeeded|failed|cached``
--
--## Depends on
--
--- OpenVibe.Contracts
--- OpenVibe.Events
--- OpenVibe.Network (quotas per actor)
--
--## Acceptance (must be true before "done")
--
--- every workflow has versioned input/output schemas and citations
--- source failures are explicit states, never fabricated content
--- private cache never crosses actor/resource scope
--- a degraded primary provider falls back and records that it did
--
--## Bootstrap / extraction source
--
--Live's AI subsystem (chat AI, moments, captions/translation, slogans, SQL/analysis jobs) migrated as workflow adapters; Network's generated-copy dependency on Live is reversed.
++The reusable AI authority extracted from OpenVibe.Live. Products run registered, versioned
++workflows; every run returns a **draft/evidence package** (structured output + citations +
++provenance) attributed to a workflow, its version, a model and a run id — never to a person. AI never
++owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
++
++## Run it
++
++```bash
++npm install
++cp .env.example .env # no key needed: without one every route falls back to the stub provider
++npm run dev # http://localhost:4700
++npm test # stub provider, temp databases, local servers only — no network
++```
++
++Node 22 (CI pins 22.22.1). Callers need an OpenVibe.Network service token with audience
++`openvibe.ai`; the Network public key is fetched from `/api/.well-known/jwks` (or pinned with
++`OV_NETWORK_PUBLIC_KEY`).
++
++## API
++
++```http
++POST /api/v1/runs?wait=10000
++Authorization: Bearer <service token, aud openvibe.ai, cap ai.run.create>
++Idempotency-Key: live-translate-8812
++
++{ "workflow": "live.translate",
++ "input": { "text": "みなさん、こんにちは", "from": "ja", "to": "en", "context": "chat" },
++ "target": { "service": "live", "type": "chat_message", "id": "8812" },
++ "attribution": { "service": "live", "type": "user", "id": "42" } }
++```
++
++answers `201` with the finished run (or `202` + `Location` while it is still running; `200` for an
++idempotent replay):
++
++```json
++{ "run": { "id": "run_01…", "status": "succeeded", "output": { "text": "Hello everyone", "unchanged": false },
++ "synthetic": false, "provenance": { "origin": "ai", "workflow": "live.translate", "workflow_version": 1,
++ "template_version": 1, "route": "live.chat", "route_version": 1, "provider": "shared", "model": "gpt-5-nano",
++ "fallback_used": false, "run_id": "run_01…" }, "citations_count": 0, "…": "…" } }
++```
++
++| Route | Capability |
++|---|---|
++| `POST /api/v1/runs` (`?wait=ms`, `Idempotency-Key`), `POST /runs/:id/cancel`, `POST /runs/:id/retry`, `POST /runs/:id/citations` | `ai.run.create` |
++| `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}` — each a run of workflow `ai.<op>` | `ai.run.create` |
++| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
++| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
++| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
++| `POST/PATCH /api/v1/providers…`, `…/disable\|enable\|reset`, `POST/PATCH /api/v1/models…`, `POST /api/v1/quotas`, `DELETE /api/v1/cache` | `ai.provider.manage` |
++| `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |
++| `GET /api/health`, `GET /api/ready` | public |
++
++Errors are `application/problem+json` (openvibe-contracts `http.problem`): `input.invalid` (with the
++schema errors), `workflow.not_found`, `idempotency.conflict`, `quota.exceeded` (429 +
++`Retry-After`), `capability.denied`, `capability.namespace_denied`, `token.*`. A run that cannot
++produce a real answer ends `failed` with an explicit code — `provider.unavailable`,
++`route.unavailable`, `fetch.refused`, `source.unavailable`, `input.insufficient`, `output.empty`,
++`output.invalid`, `run.interrupted` — and never with filler content.
++
++The capability ids are proposed for openvibe-contracts in `docs/capabilities-proposal/` (with the
++replacement `ai` service manifest). Until a contracts release defines them, `server/auth.js` checks
++them with the contracts grant rule (exact id or `family.*`). A token's `ns` claim limits which
++workflow namespaces it may run (`live.*`, `wiki.*`, …).
++
++## The eleven record groups
++
++| Group | Table(s) |
++|---|---|
++| Providers | `providers` (+ `provider_health`: circuit breaker) — secrets only as `env:NAME` references |
++| Models | `models` (type, limits, cost per Mtok, JSON/tools/streaming/vision support) |
++| Routing profiles | `routes` — versioned; primary + fallbacks, options, output limit, format, timeout, explicit aliases |
++| Prompt templates | `templates` — versioned; input/output schema, system/user prompt, default route, owner, visibility, `draft\|active\|deprecated\|archived` |
++| Workflow definitions | `workflows` — versioned; namespace, input/output schema, steps, default route, cache mode |
++| Workflow runs | `runs` — `queued\|running\|succeeded\|failed\|cancelled\|cached`, requester, on-behalf-of subject, attribution, source service, target EntityRef, idempotency key, trace id, pinned versions |
++| Request/completion log | `requests` — provider, model, route, status, skip reason, fallback flag, prompt/input/output **hashes**, tokens, cost, latency; raw prompts only with `AI_DEBUG_RAW_LOG` and a caller opt-in |
++| Sources/citations | `citations` |
++| Cache | `cache_entries` — scoped (below) |
++| Quotas/usage | `quotas`, `usage_counters`, `usage_daily` |
++| Audit | `audit_log` — every run create/cancel/retry, every provider/model/route/template/workflow/quota change, circuit changes, fallbacks |
++
++Edits to templates, workflows and routes always create a new version; old versions stay readable
++and every run records the versions it used. Boot seeding adds a new version when the code changes a
++definition, but never over an admin's version.
++
++## Providers
++
++| Kind | What | Configured by |
++|---|---|---|
++| `stub` | Deterministic, no key, realistically shaped output (schema-shaped JSON, sentences, unit-vector embeddings, timed transcript segments), clearly marked synthetic (`(synthetic)` text, `synthetic: true` on the run). Never cached. | always present |
++| `openai` | Any OpenAI-compatible API (OpenAI, OpenRouter, Groq, Together, Ollama, LM Studio, llama.cpp): chat with structured-output step-down, embeddings, Whisper-style transcription. Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL`, `AI_MODEL_<ROLE>` — Live's setting names |
++| `anthropic` | Messages API with cached system blocks and forced-tool JSON. Ported from Live's `llm.js`. | same, with `AI_PROVIDER=anthropic` |
++| `http` | The local HTTP seam: `POST {operation, …}` → `{text, json, usage}` | `AI_HTTP_SEAM_URL` or the admin API |
++| `whisper` | whisper.cpp on this host, with Live's VAD, hallucination filter, multilingual model and live/batch lanes | `WHISPER_*` — Live's names |
++
++Routing tries the route's primary, then each fallback; a provider that is disabled, missing
++credentials, circuit-open or unable to do the operation is **skipped and logged**; every call has a
++strict timeout, cancellation and one retry on a transient failure; a fallback that answers is
++recorded on the run (`fallback_used`), in the request log (`fallback = 1`) and in the audit log. The
++stub joins every route as a last resort outside production only (`AI_STUB_FALLBACK`).
++
++## Workflows
++
++- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `blog.draft_post`,
++ `news.summarize_story`, `news.compare_perspectives`, `reviews.summarize_entity`,
++ `deals.enrich_deal`, `coupons.extract_coupon`, `trade.summarize_market_context`,
++ `codes.generate_docs`, `games.generate_lore`, `moderation.classify`. They take numbered
++ `sources`, return `citations` (indices the engine bounds to the sources given) and `gaps`, and
++ never invent facts: Reviews has no rating field, a Deals price is null unless cited, a Coupons
++ expiry stays unknown unless stated, Trade carries its informational-only disclaimer. The
++ historical route keys (`wiki.generate`, `news.summarize`, …) are explicit aliases of `default.json`.
++- **Live's features** — `live.translate`, `live.paste.describe_image`, `live.paste.summarize_text`,
++ `live.stream.describe_frame`, `live.stream.summarize`, `live.streamer.overview`,
++ `live.media.overview`, `live.stream.recap`, `live.media.transcribe`, `network.site_copy`
++ (prompts moved verbatim), plus passthrough workflows for the features whose prompts Live still
++ renders (`live.viewers.*`, `live.chat.insight`, `live.moments.*`, `live.hero.slogans`,
++ `live.arena.*`, …). See `docs/migration.md`.
++- **Direct operations** — `ai.chat|generate|summarize|classify|extract|enrich|embed`.
++
++## Guarantees and where they are tested
++
++| Guarantee | Test |
++|---|---|
++| Every workflow has versioned, compiling input/output schemas, runs on the stub and validates | `test/workflows.test.js` |
++| Edits are new versions; runs pin versions; lifecycle; admin versions survive reseeding | `test/versioning.test.js` |
++| A degraded primary falls back and records it; breaker; skips; timeouts; explicit `provider.unavailable` | `test/fallback.test.js` |
++| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
++| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
++| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
++| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and redirects re-checked; size caps | `test/ssrf.test.js` |
++| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
++| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
++| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
++| Capability proposals are valid contracts documents matching what is enforced | `test/proposals.test.js` |
++
++## Live and Network
++
++`docs/live-patch.diff` (applies to Live's `main` with `git apply`) adds `AI_SERVICE=remote`: Live's
++shared-key AI calls become runs here, authenticated with `serviceHeaders('openvibe.ai')` from Live's
++`server/net/network-principal.js`. Default behaviour is unchanged. `scripts/import-from-live.js`
++moves the AI records that belong here (`--dry-run` first). Network's footer copy becomes the
++`network.site_copy` workflow — the dependency on Live's internal endpoint is reversed. The full
++plan, holds and rollback are in `docs/migration.md`.
++
++## Deploy (when launched)
++
++`/opt/openvibe.ai`, env `/etc/openvibe/ai.env` (0600), unit `deploy/systemd/openvibe-ai.service`
++(`StateDirectory=openvibe-ai`, database `/var/lib/openvibe-ai/ai.db`), nginx
++`deploy/nginx/ai.openvibe.network.conf` (only health/ready public; the API is host-local).
++
++## Not done yet
++
++- Contracts: the capability ids and service manifest are proposals; the CI contract check reports
++ the old charter manifest until a contracts release ships them.
++- `ai.run.*` events to OpenVibe.Events; per-actor (BYO) provider secrets; moving Live's local
++ transcription and the passthrough prompts; Network's direct call to `network.site_copy`; a
++ server-rendered status page.
## Launch rule
ddiff --git a/STATUS.json b/STATUS.json
iindex fada96e..9387adc 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,17 @@
{
"repository": "OpenVibers/OpenVibe.AI",
-- "stage": "placeholder",
-- "code": false,
++ "stage": "alpha",
++ "code": true,
++ "wave": "W13",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-21"
++ "runtime": "node 22, express, better-sqlite3",
++ "port": 4700,
++ "domain": "ai.openvibe.network",
++ "contracts": "openvibe-contracts v0.6.0",
++ "capabilities": ["ai.run.create", "ai.run.read", "ai.workflow.manage", "ai.provider.manage", "ai.usage.read"],
++ "capabilitiesRegistered": false,
++ "providers": ["stub", "openai-compatible", "anthropic", "http-seam", "whisper.cpp"],
++ "livePatch": "docs/live-patch.diff (AI_SERVICE=remote, not applied)",
++ "deployed": false,
++ "updated": "2026-09-23"
}
ddiff --git a/deploy/nginx/ai.openvibe.network.conf b/deploy/nginx/ai.openvibe.network.conf
nnew file mode 100644
iindex 0000000..071a83d
---- /dev/null
++++ b/deploy/nginx/ai.openvibe.network.conf
@@@ -0,0 +1,67 @@
++## ═══════════════════════════════════════════════════════════════
++## ai.openvibe.network — Nginx Configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/ai.openvibe.network.conf — keep them in sync, and reload
++## nginx after changing it. The domain keeps its OpenVibe.Sites placeholder until the launch rule in
++## README.md is met; this vhost is what replaces it then.
++##
++## The Node service (port 4700, systemd unit openvibe-ai) serves:
++## /api/health, /api/ready
++## /api/v1/* service-to-service API (Network service tokens, audience openvibe.ai). Services on
++## this host call 127.0.0.1:4700 directly, so the public vhost refuses it; open the
++## location below only when a service on another host needs it (it still needs a token).
++## ═══════════════════════════════════════════════════════════════
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name ai.openvibe.network;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://ai.openvibe.network$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name ai.openvibe.network;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/ai.openvibe.network.access.log;
++ error_log /var/log/nginx/ai.openvibe.network.error.log;
++
++ # Inline images arrive as data URLs inside run inputs.
++ client_max_body_size 12m;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ location = /api/health { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; }
++ location = /api/ready { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; }
++
++ # ── Service API: host-local only (see header) ───────────
++ location /api/ {
++ return 403;
++ # To expose it (token-guarded) to off-host services instead:
++ # proxy_pass http://127.0.0.1:4700;
++ # proxy_http_version 1.1;
++ # proxy_set_header Host $host;
++ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ # proxy_set_header X-Forwarded-Proto $scheme;
++ # proxy_read_timeout 90s; # ?wait= holds a request up to 60 s
++ }
++
++ location = / {
++ proxy_pass http://127.0.0.1:4700;
++ proxy_set_header Host $host;
++ }
++
++ location / {
++ return 404;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-ai.service b/deploy/systemd/openvibe-ai.service
nnew file mode 100644
iindex 0000000..5ab88ec
---- /dev/null
++++ b/deploy/systemd/openvibe-ai.service
@@@ -0,0 +1,34 @@
++[Unit]
++Description=OpenVibe.AI — providers, routing, versioned templates and workflows, runs, citations, cache, quotas and audit
++# Network (openvibe-network) signs the service tokens callers present. The AI service starts without
++# it and keeps retrying the JWKS fetch; /api/ready stays 503 until the key has loaded.
++After=network.target
++Wants=openvibe-network.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.ai
++EnvironmentFile=/etc/openvibe/ai.env
++Environment=NODE_ENV=production
++Environment=PORT=4700
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.ai/server/index.js
++Restart=always
++RestartSec=5
++# SIGTERM: stop accepting; runs still in flight end as failed/run.interrupted (callers can retry).
++KillSignal=SIGTERM
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=read-only
++# whisper.cpp (live.media.transcribe) runs as a child process; it reads its binary and models from
++# WHISPER_BIN / WHISPER_MODEL* (defaults under ~/whisper.cpp, readable with ProtectHome=read-only).
++# The code tree stays read-only; the database lives in /var/lib/openvibe-ai.
++StateDirectory=openvibe-ai
++Environment=AI_DB_PATH=/var/lib/openvibe-ai/ai.db
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/docs/capabilities-proposal/ai.provider.manage.json b/docs/capabilities-proposal/ai.provider.manage.json
nnew file mode 100644
iindex 0000000..aaf3d72
---- /dev/null
++++ b/docs/capabilities-proposal/ai.provider.manage.json
@@@ -0,0 +1,27 @@
++{
++ "id": "ai.provider.manage",
++ "version": "1.0",
++ "owner": "ai",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Register, update, enable and disable providers and models (secrets only as env references, never values), reset circuit breakers, set quotas and purge the cache.",
++ "permissions": [
++ "ai:admin"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ai-admin",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/providers",
++ "PATCH /api/v1/providers/:key",
++ "POST /api/v1/providers/:key/disable",
++ "POST /api/v1/providers/:key/enable",
++ "POST /api/v1/providers/:key/reset",
++ "POST /api/v1/models",
++ "PATCH /api/v1/models/:provider/:model",
++ "POST /api/v1/quotas",
++ "DELETE /api/v1/cache"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/ai.run.create.json b/docs/capabilities-proposal/ai.run.create.json
nnew file mode 100644
iindex 0000000..60cb761
---- /dev/null
++++ b/docs/capabilities-proposal/ai.run.create.json
@@@ -0,0 +1,30 @@
++{
++ "id": "ai.run.create",
++ "version": "1.0",
++ "owner": "ai",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Create workflow runs (POST /api/v1/runs and the direct chat/generate/summarize/classify/extract/enrich/embed operations), cancel or retry the caller's own runs and attach citations to them. A token's `ns` claim limits which workflow namespaces it may run (live.*, wiki.*, ...). Quotas are checked before any provider call.",
++ "permissions": [
++ "ai:run"
++ ],
++ "resourceConstraints": [
++ "namespace",
++ "owner"
++ ],
++ "quotaClass": "ai-run",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/runs",
++ "POST /api/v1/runs/:id/cancel",
++ "POST /api/v1/runs/:id/retry",
++ "POST /api/v1/runs/:id/citations",
++ "POST /api/v1/chat",
++ "POST /api/v1/generate",
++ "POST /api/v1/summarize",
++ "POST /api/v1/classify",
++ "POST /api/v1/extract",
++ "POST /api/v1/enrich",
++ "POST /api/v1/embed"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/ai.run.read.json b/docs/capabilities-proposal/ai.run.read.json
nnew file mode 100644
iindex 0000000..b07eb1b
---- /dev/null
++++ b/docs/capabilities-proposal/ai.run.read.json
@@@ -0,0 +1,21 @@
++{
++ "id": "ai.run.read",
++ "version": "1.0",
++ "owner": "ai",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read the caller's own runs, their citations and request-log metadata (hashes, tokens, latency; never raw prompts).",
++ "permissions": [
++ "ai:read"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "ai-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/runs",
++ "GET /api/v1/runs/:id",
++ "GET /api/v1/runs/:id/citations"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/ai.usage.read.json b/docs/capabilities-proposal/ai.usage.read.json
nnew file mode 100644
iindex 0000000..e9f564c
---- /dev/null
++++ b/docs/capabilities-proposal/ai.usage.read.json
@@@ -0,0 +1,26 @@
++{
++ "id": "ai.usage.read",
++ "version": "1.0",
++ "owner": "ai",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Read usage, quota windows, request logs, the audit log, cache statistics, the admin summary and any caller's runs.",
++ "permissions": [
++ "ai:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ai-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/status",
++ "GET /api/v1/usage",
++ "GET /api/v1/quotas",
++ "GET /api/v1/requests",
++ "GET /api/v1/audit",
++ "GET /api/v1/cache",
++ "GET /api/v1/providers",
++ "GET /api/v1/models"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/ai.workflow.manage.json b/docs/capabilities-proposal/ai.workflow.manage.json
nnew file mode 100644
iindex 0000000..dad462d
---- /dev/null
++++ b/docs/capabilities-proposal/ai.workflow.manage.json
@@@ -0,0 +1,24 @@
++{
++ "id": "ai.workflow.manage",
++ "version": "1.0",
++ "owner": "ai",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Create new versions of prompt templates, workflow definitions and routing profiles, and change their lifecycle status (draft, active, deprecated, archived; routes active/disabled). Every change is audited; edits never overwrite a version.",
++ "permissions": [
++ "ai:admin"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ai-admin",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/templates/:key/versions",
++ "POST /api/v1/templates/:key/versions/:version/status",
++ "POST /api/v1/workflows/:key/versions",
++ "POST /api/v1/workflows/:key/versions/:version/status",
++ "POST /api/v1/routes/:key/versions",
++ "POST /api/v1/routes/:key/versions/:version/status"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/service-manifest.ai.json b/docs/capabilities-proposal/service-manifest.ai.json
nnew file mode 100644
iindex 0000000..8023402
---- /dev/null
++++ b/docs/capabilities-proposal/service-manifest.ai.json
@@@ -0,0 +1,27 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.6.0 <1.0.0"
++ },
++ "id": "ai",
++ "name": "OpenVibe.AI",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.AI",
++ "domains": [
++ "ai.openvibe.network"
++ ],
++ "publicOrigin": "https://ai.openvibe.network",
++ "health": "/api/health",
++ "ready": "/api/ready",
++ "capabilities": [
++ "ai.run.create",
++ "ai.run.read",
++ "ai.workflow.manage",
++ "ai.provider.manage",
++ "ai.usage.read"
++ ],
++ "eventsProduced": [],
++ "eventsConsumed": [],
++ "namespacesOwned": [],
++ "notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached events follow once the Events client is wired in."
++}
ddiff --git a/test/proposals.test.js b/test/proposals.test.js
nnew file mode 100644
iindex 0000000..97f7e31
---- /dev/null
++++ b/test/proposals.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++// The capability manifests proposed for openvibe-contracts (docs/capabilities-proposal/) are valid
++// contracts documents, use 3+ segment ids owned by 'ai', and are exactly the ids this service enforces.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { CAPS } = require('../server/auth');
++const { suite } = require('./helpers');
++
++const t = suite('proposals');
++const dir = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
++
++t.test('every proposal validates against the contracts schemas', () => {
++ for (const f of fs.readdirSync(dir)) {
++ const doc = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
++ const v = contracts.validate(f.startsWith('service-manifest') ? 'registry.service-manifest' : 'capabilities.capability', doc);
++ assert.ok(v.valid, `${f}: ${JSON.stringify(v.errors)}`);
++ }
++});
++
++t.test('capability ids: owner ai, 3+ segments, the same set the service enforces and the manifest lists', () => {
++ const caps = fs.readdirSync(dir).filter(f => !f.startsWith('service-manifest')).map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
++ for (const c of caps) {
++ assert.strictEqual(c.owner, 'ai');
++ assert.ok(c.id.split('.').length >= 3, c.id);
++ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'ai', `${c.id} is owned elsewhere in contracts`);
++ }
++ const ids = caps.map(c => c.id).sort();
++ assert.deepStrictEqual(ids, Object.values(CAPS).sort());
++ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'service-manifest.ai.json'), 'utf8'));
++ assert.deepStrictEqual([...manifest.capabilities].sort(), ids);
++});
++
++t.run();