-```
-
-**Context hooks replaced with unified state API:**
-
-All individual context hooks replaced by `useAuiState` / `useAui`. The right-hand side below is the 0.15 landing form (property accessors), since a project migrating today upgrades past 0.15:
-
-```diff
-- const { messages } = useThread();
-+ const messages = useAuiState(s => s.thread.messages);
-
-- const runtime = useThreadRuntime();
-+ const thread = useAui().thread;
-
-- const { isEditing } = useComposer();
-+ const isEditing = useAuiState(s => s.composer.isEditing);
-
-- const runtime = useComposerRuntime();
-+ const composer = useAui().composer;
-
-- const { status } = useMessage();
-+ const status = useAuiState(s => s.message.status);
-
-- const runtime = useMessageRuntime();
-+ const message = useAui().message;
-```
-
-Deprecated alongside them and removed outright in 0.15: `useAssistantRuntime`, `useEditComposer`, `useThreadListItem`, `useThreadListItemRuntime`, `useMessagePart`, `useMessagePartRuntime`, `useAttachment`, `useAttachmentRuntime`, `useThreadModelContext`, `useThreadComposer`, `useThreadList`. See the [0.15 section](#migration--015x-property-accessors-legacy-hook-removal) for the full mapping table.
-
-**Event names changed to camelCase:**
-
-| Old | New |
-|-----|-----|
-| `thread.run-start` | `thread.runStart` |
-| `thread.run-end` | `thread.runEnd` |
-| `thread.model-context-update` | `thread.modelContextUpdate` |
-| `composer.attachment-add` | `composer.attachmentAdd` |
-| `thread-list-item.switched-to` | `threadListItem.switchedTo` |
-| `thread-list-item.switched-away` | `threadListItem.switchedAway` |
-
-Unchanged: `thread.initialize`, `composer.send`.
-
-**`thread().composer()` invocation (0.12.11):**
-
-```diff
-- aui.thread().composer.send();
-+ aui.thread().composer().send();
-```
-
-**`submitMode` prop (0.12.10), deprecating `submitOnEnter`:**
-- `"enter"` (default): submit on Enter
-- `"ctrlEnter"`: submit on Ctrl/Cmd+Enter, plain Enter for newlines
-- `"none"`: disable keyboard submission
-
-**Zod**: AI SDK v6+ (used by `@assistant-ui/react-ai-sdk` 1.3.x and later) requires `zod@^3.25.76 || ^4.1.8`; both Zod 3.25+ and Zod 4 work.
-
-**New primitives:**
-- `ChainOfThoughtPrimitive` (0.12.8)
-- `SelectionToolbarPrimitive` (0.12.10)
-- `SuggestionPrimitive` (0.12.3)
-
-**`@assistant-ui/core` extraction (0.12.11):**
-- Framework-agnostic core extracted to `@assistant-ui/core`
-- Shared React code in `@assistant-ui/core/react` (re-exported by `@assistant-ui/react` and `@assistant-ui/react-native`)
-
-**Search for deprecated patterns:**
-```bash
-grep -rn "useAssistantApi\|useAssistantState\|useAssistantEvent\|AssistantIf\|submitOnEnter\|useThread()\|useComposer()\|useMessage()\|useThreadRuntime\|useComposerRuntime\|useMessageRuntime" --include="*.tsx" --include="*.ts"
-```
-
-Add the 0.15 patterns to the same sweep:
-
-```bash
-grep -rnE "\b(aui|api)\.(thread|threads|message|part|composer|attachment|threadListItem|modelContext|tools)\(\)" --include="*.tsx" --include="*.ts"
-grep -rn "s\.tools\.tools\|\"mcp-app\"" --include="*.tsx" --include="*.ts"
-```
-
----
-
-## Migration: → 0.11.x (Runtime Rearchitecture)
-
-### From 0.10.x
-
-**New unified state API** (hooks renamed to `useAui`/`useAuiState`/`useAuiEvent` in 0.12.x):
-
-```typescript
-import {
- useAssistantApi,
- useAssistantState,
- useAssistantEvent
-} from "@assistant-ui/react";
-
-// State access (replaces various useThread* hooks)
-const messages = useAssistantState(s => s.thread.messages);
-const isRunning = useAssistantState(s => s.thread.isRunning);
-
-const api = useAssistantApi();
-api.thread().append({ role: "user", content: [{ type: "text", text: "Hello" }] });
-api.thread().cancelRun();
-
-useAssistantEvent("composer.send", (e) => {
- console.log("Message sent:", e.messageId);
-});
-```
-
-**AI SDK v5/v6 support added:**
-- Use `useChatRuntime` for AI SDK v6
-- `useAISDKRuntime` still works for migration
-
-**Renames:**
-- `toolUIs` → `tools` (0.11.39)
-- `useLocalThreadRuntime` deprecated, use `useLocalRuntime`
-
----
-
-## Migration: → 0.10.x (ESM Only)
-
-### From 0.9.x
-
-**BREAKING: CommonJS dropped**
-
-Update bundler if needed:
-```json
-// package.json
-{
- "type": "module"
-}
-```
-
-Or configure bundler for ESM:
-```javascript
-// next.config.js
-export default {
- experimental: {
- esmExternals: true
- }
-}
-```
-
-**New APIs:**
-- `ContentPart` renamed to `MessagePart` (0.10.25)
-- `MessageContent.ToolGroup` added
-- `runtime.thread.reset()` added
-
----
-
-## Migration: → 0.9.x (Edge Split)
-
-### From 0.8.x
-
-**Edge package split:**
-- Edge runtime utilities moved to separate entry points
-- Check imports if using edge runtime
-
----
-
-## Migration: → 0.8.x (UI Split)
-
-### From 0.7.x
-
-**BREAKING: Pre-styled UI moved out of `@assistant-ui/react`**
-
-0.7.x: `Thread` etc. were re-exported from `@assistant-ui/react` via `./ui` subpath
-0.8.0+: Use shadcn/ui registry (recommended) or `@assistant-ui/react-ui` (legacy, not maintained)
-
-**Option 1: shadcn/ui Registry (Recommended)**
-
-```bash
-# Using assistant-ui CLI
-npx assistant-ui add thread thread-list
-
-# Or using shadcn CLI
-npx shadcn@latest add "https://r.assistant-ui.com/thread"
-```
-
-Components are copied to your project (e.g., `components/assistant-ui/thread.tsx`).
-
-```diff
-// Styled components - now local files
-// Note: ThreadWelcome is now embedded inside Thread (shows when thread is empty)
-- import { Thread, ThreadWelcome } from "@assistant-ui/react";
-+ import { Thread } from "@/components/assistant-ui/thread";
-
-// Primitives remain in @assistant-ui/react (no change)
-import { ThreadPrimitive } from "@assistant-ui/react";
-```
-
-**Option 2: Legacy Package (Not Recommended)**
-
-`@assistant-ui/react-ui` exists but is not actively maintained.
-
-**Search for imports to update:**
-```bash
-grep -r "from ['\"]@assistant-ui/react['\"]" --include="*.tsx" --include="*.ts" | grep -v "Primitive"
-```
-
-**setResult/setArtifact merged (0.8.18):**
-```diff
-- tool.setResult(result);
-- tool.setArtifact(artifact);
-+ tool.setResponse({ result, artifact });
-```
-
----
-
-## Migration: → 0.7.x (Thread API)
-
-### From 0.6.x or 0.5.x
-
-**BREAKING (0.7.44): Thread API moved**
-
-```diff
-- runtime.switchToThread(threadId);
-+ runtime.threads.switchToThread(threadId);
-
-- runtime.switchToNewThread();
-+ runtime.threads.switchToNewThread();
-
-- runtime.threadList
-+ runtime.threads
-```
-
-**Search:**
-```bash
-grep -r "runtime\.switchToThread\|runtime\.switchToNewThread\|runtime\.threadList" --include="*.tsx" --include="*.ts"
-```
-
-**Deprecated features dropped (0.7.0):**
-- All previously deprecated APIs removed
-- `ThreadListItemPrimitive` introduced
-
----
-
-## Migration: → 0.5.x (Runtime API)
-
-### From 0.4.x
-
-**maxToolRoundtrips → maxSteps (0.5.74):**
-```diff
-- maxToolRoundtrips: 5,
-+ maxSteps: 5,
-```
-
-**New Runtime API introduced (0.5.61+):**
-- `ThreadRuntime.Composer`
-- Status/attachments/metadata on all messages
-
----
-
-## Migration: → 0.4.x (Message Types)
-
-### From 0.3.x
-
-**BREAKING: Message type renames**
-
-```diff
-- import type { AssistantMessage, UserMessage } from "@assistant-ui/react";
-+ import type { ThreadAssistantMessage, ThreadUserMessage } from "@assistant-ui/react";
-```
-
-**Search:**
-```bash
-grep -r "AssistantMessage\|UserMessage" --include="*.tsx" --include="*.ts" | grep -v "Thread"
-```
-
-**System message support added**
-
----
-
-## Migration: → 0.3.x
-
-### From 0.2.x
-
-**BREAKING: Message.InProgress dropped**
-- Use message status instead of `Message.InProgress`
-
----
-
-## Migration: → 0.2.x
-
-### From 0.1.x
-
-**BREAKING: MessagePartText renders as ``**
-- Text parts now wrapped in paragraph element
-- Adjust CSS if needed
-
----
-
-## Automated Search Commands
-
-Find patterns that need updating:
-
-```bash
-# Old thread API
-grep -rn "runtime\.switchToThread\|runtime\.threadList" --include="*.tsx" --include="*.ts"
-
-# Old message types
-grep -rn "AssistantMessage\[^C\]\|UserMessage\[^C\]" --include="*.tsx" --include="*.ts"
-
-# Old tool API
-grep -rn "setResult\|setArtifact" --include="*.tsx" --include="*.ts"
-
-# Styled imports (need shadcn registry migration)
-grep -rn "from ['\"]@assistant-ui/react['\"]" --include="*.tsx" | grep -v "Primitive\|Runtime\|use"
-```
-
-## Verification
-
-After migration:
-
-```bash
-# Type check
-npx tsc --noEmit
-
-# Build
-pnpm build
-
-# Test
-pnpm test
-```
-
-Manual verification:
-- [ ] App starts
-- [ ] Chat renders
-- [ ] Messages send/receive
-- [ ] Tools work
-- [ ] Thread switching works
diff --git a/.agents/skills/update/references/breaking-changes.md b/.agents/skills/update/references/breaking-changes.md
deleted file mode 100644
index bd7d6201..00000000
--- a/.agents/skills/update/references/breaking-changes.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# Breaking Changes Quick Reference
-
-Fast lookup for breaking changes by version.
-
-## By Version
-
-| Version | Breaking Change | Migration |
-|---------|-----------------|-----------|
-| **0.15.0** | `aui` scope accessors become properties; v0.12-era legacy context hooks removed; `ToolsState.tools` removed; `"mcp-app"` group key removed; `useAui(clients, { parent })` removed | `aui.thread` not `aui.thread()`; `useAui`/`useAuiState`; `s.tools.toolUIs[n]?.[0]?.render`; `"standalone-tool-call"`; `AuiProvider` |
-| **0.14.0** | `components` prop → children render functions; deprecated hooks/aliases removed | Children render functions; `useAui`/`useAuiState`/`useAuiEvent`/`AuiIf`; drop `unstable_` prefixes |
-| **0.13.0** | `ThreadPrimitive.ViewportSlack` removed | Use `topAnchorMessageClamp` on `ThreadPrimitive.Viewport` |
-| **0.12.0** | Unified state API | Use `useAui`, `useAuiState`, `useAuiEvent`, `AuiIf` |
-| **0.11.0** | Runtime rearchitecture | Use `useAssistantApi`/`useAssistantState` (renamed to `useAui`/`useAuiState` in 0.12) |
-| **0.10.0** | CommonJS dropped | Use ESM, set `"type": "module"` |
-| **0.8.18** | `setResult`/`setArtifact` merged | Use `setResponse({ result, artifact })` |
-| **0.8.0** | UI moved out of core | Use shadcn registry (recommended) or primitives |
-| **0.7.44** | `runtime.switchToThread()` moved | Use `runtime.threads.switchToThread()` |
-| **0.7.44** | `runtime.threadList` renamed | Use `runtime.threads` |
-| **0.7.0** | Deprecated features dropped | Update to non-deprecated APIs |
-| **0.5.74** | `maxToolRoundtrips` renamed | Use `maxSteps` |
-| **0.4.0** | `AssistantMessage` renamed | Use `ThreadAssistantMessage` |
-| **0.4.0** | `UserMessage` renamed | Use `ThreadUserMessage` |
-| **0.3.0** | `Message.InProgress` dropped | Use message status |
-| **0.2.0** | `MessagePartText` renders as `
` | Adjust CSS |
-
-## By Pattern
-
-### Import Changes
-
-```diff
-# Styled components (0.8.0+) - use shadcn registry (recommended)
-- import { Thread } from "@assistant-ui/react";
-+ import { Thread } from "@/components/assistant-ui/thread";
-# Note: Run `npx assistant-ui add thread` to install
-
-# Message types (0.4.0+)
-- import type { AssistantMessage, UserMessage } from "@assistant-ui/react";
-+ import type { ThreadAssistantMessage, ThreadUserMessage } from "@assistant-ui/react";
-
-# AI SDK v6+ (react-ai-sdk 1.0+)
-- import { useChat } from "ai/react";
-- import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
-+ import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk";
-```
-
-### API Changes
-
-```diff
-# Thread switching (0.7.44+)
-- runtime.switchToThread(id);
-- runtime.switchToNewThread();
-- runtime.threadList
-+ runtime.threads.switchToThread(id);
-+ runtime.threads.switchToNewThread();
-+ runtime.threads
-
-# Tool response (0.8.18+)
-- tool.setResult(result);
-- tool.setArtifact(artifact);
-+ tool.setResponse({ result, artifact });
-
-# State access (0.11.0+)
-- const { messages } = useThread();
-+ const messages = useAuiState(s => s.thread.messages);
-
-# Actions (0.11.0+)
-- useThreadActions().append(...)
-+ useAui().thread.append(...)
-
-# Scope accessors are properties (0.15.0+)
-- aui.thread().getState();
-- aui.threads().switchToNewThread();
-+ aui.thread.getState();
-+ aui.threads.switchToNewThread();
-
-# Tool UI registry (0.15.0+)
-- useAuiState((s) => s.tools.tools[toolName]?.[0]);
-+ useAuiState((s) => s.tools.toolUIs[toolName]?.[0]?.render);
-
-# Part grouping (0.15.0+)
-- groupPartByType({ "mcp-app": [] });
-+ groupPartByType({ "standalone-tool-call": [] });
-```
-
-### Config Changes
-
-```diff
-# Tool steps (0.5.74+)
-- maxToolRoundtrips: 5,
-+ maxSteps: 5,
-```
-
-## Search Commands
-
-Find code needing updates:
-
-```bash
-# All breaking patterns
-grep -rn "runtime\.switchToThread\|runtime\.threadList\|AssistantMessage[^C]\|UserMessage[^C]\|setResult\|setArtifact\|maxToolRoundtrips" --include="*.tsx" --include="*.ts"
-
-# Specific version checks
-grep -rn "from ['\"]@assistant-ui/react['\"]" --include="*.tsx" | grep -v Primitive # 0.8.0
-grep -rn "Message\.InProgress" --include="*.tsx" # 0.3.0
-```
-
-## AI SDK Changes (Separate)
-
-See [./ai-sdk-v6.md](./ai-sdk-v6.md) for the v4/v5 → v6 migration, which is still the bulk of the work for older projects:
-
-| Old (v4/v5) | v6 |
-|-----|-----|
-| `maxSteps` | `stopWhen: stepCountIs(n)` |
-| `parameters` | `inputSchema` (in `tool()`) |
-| `toDataStreamResponse()` | `toUIMessageStreamResponse()` |
-| `generateObject()` | `generateText() + Output.object()` |
-| `CoreMessage` | `ModelMessage` |
-| `Message` | `UIMessage` |
-
-v6 → v7 is a much smaller step, but the route response and the `@ai-sdk/*` major line both move:
-
-| v6 | v7 |
-|----|----|
-| `ai@^6`, `@ai-sdk/react@^3`, `@ai-sdk/openai@^3` | `ai@^7`, `@ai-sdk/react@^4`, `@ai-sdk/openai@^4` |
-| `result.toUIMessageStreamResponse()` | `createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })` (the method still compiles but is deprecated) |
-| `inputSchema: z.object({...})` | `inputSchema: zodSchema(z.object({...}))` |
-| n/a | `toolApproval` call-level option + `lastAssistantMessageIsCompleteWithApprovalResponses` |
-
-## Version Compatibility
-
-Current latest: `@assistant-ui/react` 0.15.x, `@assistant-ui/react-ai-sdk` 1.4.x, `@assistant-ui/core` 0.3.x, `@assistant-ui/store` 0.3.x, `assistant-stream` 0.3.x.
-
-| @assistant-ui/react | react-ai-sdk | AI SDK | Zod |
-|---------------------|--------------|--------|-----|
-| 0.15.x | 1.4.x | 7.x | 3.25+ or 4.x |
-| 0.14.x | 1.3.x | 6.x | 3.25+ or 4.x |
-| 0.12.x to 0.13.x | 1.3.x | 6.x | 3.25+ or 4.x |
-| 0.11.x | 1.2.x | 6.x | 3.25+ or 4.x |
-| 0.10.x | 0.x | 4.x to 5.x | 3.x |
-| 0.8.x to 0.9.x | 0.x | 4.x | 3.x |
-| < 0.8.0 | 0.x | 4.x | 3.x |
diff --git a/.codex/hooks.json b/.codex/hooks.json
new file mode 100644
index 00000000..c081c1ef
--- /dev/null
+++ b/.codex/hooks.json
@@ -0,0 +1,16 @@
+{
+ "hooks": {
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node .codex/hooks/production-skill-sync.mjs",
+ "timeout": 5,
+ "statusMessage": "Checking production skill synchronization..."
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.codex/hooks/production-skill-sync.mjs b/.codex/hooks/production-skill-sync.mjs
new file mode 100644
index 00000000..6fa226b6
--- /dev/null
+++ b/.codex/hooks/production-skill-sync.mjs
@@ -0,0 +1,10 @@
+let input = "";
+for await (const chunk of process.stdin) input += chunk;
+const payload = JSON.parse(input.replace(/^\uFEFF/, ""));
+
+if (!payload.stop_hook_active) {
+ console.log(JSON.stringify({
+ decision: "block",
+ reason: "Before the final response, check whether this turn changed live production. If it did, update the applicable operate-openship-production references without secrets and validate that skill. If it did not, continue without changing it.",
+ }));
+}
diff --git a/.env.example b/.env.example
index ac6886da..86af8234 100644
--- a/.env.example
+++ b/.env.example
@@ -12,6 +12,7 @@
# same `.env` is read by `docker compose` — see the README's "MVP:
# single-server Docker Compose" section. DATABASE_URL / REDIS_URL /
# WuKongIM and Agent OS internal URLs are pinned to Compose service names.
+# CI writes immutable commit tags directly into deployment manifests.
# ─── Core ─────────────────────────────────────────────────────────────────
# Port the API + WS server listens on. Defaults to 5181.
@@ -27,17 +28,25 @@ NODE_ENV=development
# Redis pubsub URL (used by WS bridge + agent scheduler).
# REDIS_URL=redis://localhost:6379
-# ─── DeepSeek (the only model provider; required) ─────────────────────────
-DEEPSEEK_API_KEY=sk-...
-# May be changed to an operator-approved DeepSeek-compatible gateway.
-DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
-# One global model for main turns, compaction and learning utilities.
-DEEPSEEK_MODEL=deepseek-chat
-# Official DeepSeek does not expose these capabilities. Leave empty to use
-# recency memory and uploaded avatars, or name models exposed by the same
-# approved DeepSeek gateway.
-DEEPSEEK_EMBEDDING_MODEL=
-DEEPSEEK_IMAGE_MODEL=
+# ─── OpenAI API (the only model provider; required) ───────────────────────
+OPENAI_API_KEY=sk-...
+OPENAI_BASE_URL=https://api.openai.com/v1
+OPENAI_MODEL=gpt-5-mini
+OPENAI_EMBEDDING_MODEL=text-embedding-3-small
+# Optional only when generated avatars are enabled.
+OPENAI_IMAGE_MODEL=
+
+# ─── LingxiLit AI observability (optional; disabled without OTLP endpoint) ──
+# Point this at the independently deployed LingxiLit OTLP/HTTP receiver.
+OTEL_EXPORTER_OTLP_ENDPOINT=
+# Optional comma-separated OTLP headers, for example Authorization=Bearer%20token.
+OTEL_EXPORTER_OTLP_HEADERS=
+OTEL_SERVICE_NAME=lingxiloop-web
+OTEL_DEPLOYMENT_ENVIRONMENT=development
+# LingxiLit-compatible pricing JSON URL with the LingxiLoop `limits` extension.
+LINGXILIT_PRICING_JSON=
+# Refine build-time link to the authenticated LingxiLit dashboard.
+VITE_LINGXILIT_URL=
# Independent Agent OS service.
# Generate both secrets with a cryptographic random generator in production.
@@ -47,7 +56,9 @@ AGENT_OS_PORT=5190
AGENT_OS_PYTHON=python3
AGENT_OS_HOMES_ROOT=.agent-os/homes
AGENT_OS_MAX_CONCURRENT_RUNS=8
+AGENT_OS_NODE_TIMEOUT_SECONDS=15
LINGXILOOP_LOG_LEVEL=warn
+LINGXILOOP_GATEWAY_HMAC_SECRET=change-me-gateway-hmac
# ─── Open Notebook native knowledge engine ───────────────────────────────
# Browser and Agent OS never connect to this endpoint directly. Keep it on
@@ -55,16 +66,11 @@ LINGXILOOP_LOG_LEVEL=warn
OPEN_NOTEBOOK_ENABLED=true
OPEN_NOTEBOOK_URL=http://localhost:5055
OPEN_NOTEBOOK_PASSWORD=change-me-open-notebook-password
-OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-open-notebook-encryption-key
OPEN_NOTEBOOK_SURREAL_USER=open-notebook
OPEN_NOTEBOOK_SURREAL_PASSWORD=change-me-surreal-password
OPEN_NOTEBOOK_WORKER_MAX_TASKS=5
-# IDs are Open Notebook model record IDs configured by operations. The chat
-# value is the fallback for all Ask stages when stage-specific IDs are empty.
-OPEN_NOTEBOOK_CHAT_MODEL=
-OPEN_NOTEBOOK_STRATEGY_MODEL=
-OPEN_NOTEBOOK_ANSWER_MODEL=
-OPEN_NOTEBOOK_FINAL_ANSWER_MODEL=
+# The RAG service bootstraps one OpenAI-compatible embedding model from the
+# OPENAI_* values above. Changing it requires a complete knowledge-index reset.
# WuKongIM v3 Beta messaging authority.
WUKONG_API_URL=http://localhost:5001
@@ -73,17 +79,16 @@ WUKONG_API_TOKEN=change-me
WUKONG_USER_TOKEN_SECRET=change-me
WUKONG_WEBHOOK_SECRET=change-me
# Only enable when WuKong and LingxiLoop share a private container network.
-WUKONG_WEBHOOK_ALLOW_UNSIGNED_INTERNAL=false
# ─── Publicly reachable host ──────────────────────────────────────────────
# PUBLIC_HOST=https://lingxiloop-dev.ngrok.app
# ─── Cloudflare R2 (object storage) ───────────────────────────────────────
-# When ALL four core R2_ vars are set, the storage layer flips to R2 mode:
+# R2 is the only V1 object-storage path. All values below are mandatory:
# - browser uploads go directly to R2 via presigned PUT
# - avatar generation persists to R2
# - server/uploads/ static-serve is disabled
-# Leave any of these blank and the server falls back to local disk.
+# The Web/Worker processes fail fast when any value is blank.
# Bucket endpoint. For R2 this is `https://.r2.cloudflarestorage.com`.
# Find your account id at https://dash.cloudflare.com → R2 → "Use R2 with APIs".
@@ -99,26 +104,24 @@ R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
# Open Notebook reuses the same private bucket and credentials. In "auto"
-# mode, uploaded Source originals and generated Podcast audio move to R2 when
-# all four core R2 variables above are configured. Its database/checkpoints and
-# processing caches remain on the Open Notebook data volume.
+# mode, uploaded Source originals move to R2 when
+# all four core R2 variables above are configured. Its database and processing
+# caches remain on the Open Notebook volumes.
OPEN_NOTEBOOK_R2_ENABLED=auto
OPEN_NOTEBOOK_R2_PREFIX=open-notebook
# Public read base — your CDN / custom domain or the bucket's r2.dev URL.
# When set, public URLs are stable and cacheable (`/`).
-# When blank, the server emits short-lived presigned GET URLs instead —
-# still works for the chat client, but uncacheable and rotates.
-# R2_PUBLIC_BASE=https://cdn.example.com
-R2_PUBLIC_BASE=
+# This is mandatory; no presigned-GET or local-disk read path exists.
+R2_PUBLIC_BASE=https://cdn.example.com
# HMAC secret shared between this server and the lingxiloop-r2-gate Worker
# (see workers/r2-gate). When set, public URLs are emitted with a signed
# `?exp&sig` query string and the Worker validates them before reading R2.
# Generate with `openssl rand -hex 32`. Push the SAME value to the Worker
# via `cd workers/r2-gate && npx wrangler secret put R2_URL_SIGNING_SECRET`.
-# Leave blank to serve unsigned URLs (only OK in dev / local mode).
-R2_URL_SIGNING_SECRET=
+# This is mandatory in every runtime, including local development.
+R2_URL_SIGNING_SECRET=replace-with-a-long-random-secret
# TTL (seconds) baked into each signed URL. Default 1 hour. Re-signing
# happens on every read, so users see fresh URLs each session.
# R2_URL_TTL_SECONDS=3600
@@ -160,12 +163,15 @@ R2_URL_SIGNING_SECRET=
# Public base used to construct invitation accept URLs
# (`/invite/`). Defaults to LINGXILOOP_AUTH_DONE_URL — set this
# only if the invite-accept flow lives on a different origin.
-# LINGXILOOP_INVITE_BASE_URL=https://loop.example.com
+LINGXILOOP_INVITE_BASE_URL=https://loop.example.com
#
# CORS allow-list. Same-origin web (SPA served from the SAME backend at
# the public origin) doesn't need this. Set it when a separately-hosted web
# bundle (or packaged Electron) talks to this API from another origin.
# LINGXILOOP_CORS_ORIGINS=https://loop.example.com,app://lingxiloop
+# Comma-separated verified LingxiIdentity emails allowed to use /api/admin.
+# Keep Cloudflare Access in front of the admin site as an additional gate.
+# LINGXILOOP_PLATFORM_ADMIN_EMAILS=operator@example.com
# ─── Product analytics (optional) ────────────────────────────────────────
# PostHog is only initialized when VITE_PUBLIC_POSTHOG_KEY is set. These
@@ -176,6 +182,10 @@ R2_URL_SIGNING_SECRET=
# Desktop release builds must receive a clean HTTPS API origin at build time.
# No production API fallback is committed.
# VITE_LINGXILOOP_API_BASE=https://loop.example.com
+# Public Turnstile key baked into the Web bundle. Local development falls
+# back to Cloudflare's official always-pass test key.
+VITE_TURNSTILE_SITE_KEY=
+# Set TURNSTILE_SECRET_KEY only as a control-plane Worker secret; never commit it.
# ─── SkillHub (Agent Skills registry) ────────────────────────────────────
# Base URL of an Agent Skills hub. Agents use it via
@@ -191,38 +201,32 @@ R2_URL_SIGNING_SECRET=
SKILLHUB_URL=
# ─── Email (real external mail per agent) ────────────────────────────────
-# When all three are set, agents can send and receive real email. Outbound
-# goes through Resend (HTTP API). Inbound is fronted by a Cloudflare Email
-# Worker (workers/email-gate) that POSTs parsed JSON to
-# `/webhooks/email/inbound`, signed with EMAIL_INBOUND_HMAC_SECRET.
+# Agents send through the Resend HTTP API and receive through Resend Receiving.
+# Resend POSTs a Svix-signed `email.received` event to
+# `/webhooks/email/resend`; the server then retrieves the complete message and
+# attachments through the Receiving API.
#
# Each agent's address is `.@`,
# e.g. aurora.acme@mail.loop.example.com. Apex domain on purpose — every tenant
# shares one Resend-verified domain (DKIM/SPF set up once for EMAIL_DOMAIN
# itself, no per-tenant work).
#
-# Leave RESEND_API_KEY blank to run in mock mode — `lingxiloop email send`
-# returns a fake message-id and logs instead of hitting Resend. Useful for
-# local dev. Inbound is gated separately on EMAIL_INBOUND_HMAC_SECRET.
+# Leave these blank only when email is intentionally unavailable. The runtime
+# never fabricates provider success.
#
# Setup:
-# 1. Resend: create an API key, add SPF + DKIM TXT records for EMAIL_DOMAIN.
-# 2. Cloudflare: enable Email Routing on EMAIL_DOMAIN, deploy the
-# workers/email-gate worker as the catch-all for *@.
-# 3. See docs/email.md for the full walkthrough.
+# 1. Resend: create an API key and configure sending + receiving DNS.
+# 2. Add an `email.received` webhook for
+# https:///webhooks/email/resend.
+# 3. Copy its endpoint-specific whsec_ value below.
# RESEND_API_KEY=re_xxxxxxxx
+# RESEND_WEBHOOK_SECRET=whsec_xxxxxxxx
# EMAIL_DOMAIN=mail.loop.example.com
-# EMAIL_INBOUND_HMAC_SECRET=
RESEND_API_KEY=
+RESEND_WEBHOOK_SECRET=
EMAIL_DOMAIN=
-EMAIL_INBOUND_HMAC_SECRET=
# ─── Packaged MVP image pulls ─────────────────────────────────────────────
-# Project packages are published by CI to GHCR. The default accelerator turns
-# ghcr.io/lingxi-org/... into accel.way2api.fun/ghcr.io/lingxi-org/....
-GHCR_ACCELERATOR=accel.way2api.fun/ghcr.io
-LINGXILOOP_IMAGE_TAG=mvp
-# Use GHCR directly when no accelerator is needed:
-# GHCR_ACCELERATOR=ghcr.io
+# Project images use CI-managed commit tags; no image variables are required.
# Web and Desktop clients reach the API and WuKong WebSocket through these
# published host ports. Put TLS/Caddy in front for any non-LAN deployment.
LINGXILOOP_BIND_ADDRESS=0.0.0.0
@@ -234,3 +238,8 @@ WUKONG_WS_PORT=5200
# PGVECTOR_IMAGE=docker.m.daocloud.io/pgvector/pgvector:pg16
# REDIS_IMAGE=docker.m.daocloud.io/library/redis:7-alpine
# PIP_EXTRA_INDEX_URL=
+# Source-grounded, self-contained HTML lecture decks. Keep disabled until R2,
+# Open Notebook presentation-material and the pinned renderer assets are ready.
+# Publication uses deterministic static/geometry gates and does not install or
+# launch Chromium in the server or Worker.
+PRESENTATION_HTML_ENABLED=false
diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 00000000..7cc3179b
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,42 @@
+# Copy this file to .env.local. Never commit .env.local.
+DATABASE_URL=postgres://lingxiloop:lingxiloop@127.0.0.1:5432/lingxiloop
+REDIS_URL=redis://127.0.0.1:6379
+
+# OpenAI-compatible provider supplied by the developer.
+OPENAI_BASE_URL=https://api.openai.com/v1
+OPENAI_API_KEY=
+OPENAI_MODEL=
+OPENAI_EMBEDDING_MODEL=text-embedding-3-small
+OPENAI_IMAGE_MODEL=
+
+# Product identity is issued by the Hono + Better Auth Worker. The API accepts
+# only signed gateway assertions carrying this shared secret.
+LINGXILOOP_PUBLIC_ORIGIN=http://localhost:5181
+LINGXILOOP_GATEWAY_HMAC_SECRET=replace-with-the-worker-gateway-secret
+LINGXILOOP_INVITE_BASE_URL=http://localhost:5180
+
+# WuKongIM and Agent OS use the same local services as production.
+WUKONG_API_URL=http://127.0.0.1:5001
+WUKONG_WS_URL=ws://127.0.0.1:5200
+WUKONG_API_TOKEN=dev-wukong-api-token
+WUKONG_WEBHOOK_SECRET=dev-wukong-webhook-secret
+WUKONG_USER_TOKEN_SECRET=replace-with-a-long-random-secret
+AGENT_OS_SERVICE_TOKEN=replace-with-a-second-long-random-secret
+LINGXILOOP_CONTROL_PLANE_URL=http://127.0.0.1:5181
+
+# Cloudflare R2 supplied by the developer.
+R2_ENDPOINT=
+R2_BUCKET=
+R2_ACCESS_KEY_ID=
+R2_SECRET_ACCESS_KEY=
+R2_PUBLIC_BASE=
+R2_URL_SIGNING_SECRET=
+R2_URL_TTL_SECONDS=3600
+
+# Resend and inbound email supplied by the developer.
+RESEND_API_KEY=
+RESEND_WEBHOOK_SECRET=
+EMAIL_DOMAIN=
+
+# Optional local knowledge service.
+OPEN_NOTEBOOK_ENABLED=false
diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml
deleted file mode 100644
index f0ad7152..00000000
--- a/.github/workflows/_quality.yml
+++ /dev/null
@@ -1,270 +0,0 @@
-name: LingxiLoop reusable quality
-
-on:
- workflow_call:
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- classify:
- name: Classify changed domains
- runs-on: ubuntu-latest
- timeout-minutes: 3
- outputs:
- full: ${{ steps.plan.outputs.full }}
- eval: ${{ steps.plan.outputs.eval }}
- eval_focused: ${{ steps.plan.outputs.eval_focused }}
- dashboard: ${{ steps.plan.outputs.dashboard }}
- frontend: ${{ steps.plan.outputs.frontend }}
- server: ${{ steps.plan.outputs.server }}
- agent_os: ${{ steps.plan.outputs.agent_os }}
- build_release: ${{ steps.plan.outputs.build_release }}
- open_notebook: ${{ steps.plan.outputs.open_notebook }}
- compose: ${{ steps.plan.outputs.compose }}
- desktop: ${{ steps.plan.outputs.desktop }}
- build: ${{ steps.plan.outputs.build }}
- full_unit: ${{ steps.plan.outputs.full_unit }}
- integration: ${{ steps.plan.outputs.integration }}
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Verify change classifier when its implementation changes
- if: github.event_name == 'pull_request'
- env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- shell: bash
- run: |
- if git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- \
- .agents/skills/lingxiloop-verify-change/scripts/classify-change.mjs \
- .agents/skills/lingxiloop-verify-change/scripts/classify-change.test.mjs; then
- echo "Classifier unchanged; self-test skipped."
- else
- node --test .agents/skills/lingxiloop-verify-change/scripts/classify-change.test.mjs
- fi
- - id: plan
- name: Build quality plan with lingxiloop-verify-change
- env:
- FULL_MATRIX: ${{ github.event_name != 'pull_request' }}
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- shell: bash
- run: |
- if [[ "$FULL_MATRIX" == "true" ]]; then
- printf '{"ci":{}}\n' > change-plan.json
- else
- node .agents/skills/lingxiloop-verify-change/scripts/classify-change.mjs \
- --base "$BASE_SHA" --head "$HEAD_SHA" --format json > change-plan.json
- fi
- node --input-type=module <<'NODE'
- import { appendFileSync, readFileSync } from 'node:fs'
- const report = JSON.parse(readFileSync('change-plan.json', 'utf8'))
- const ci = report.ci ?? {}
- const output = {
- full: process.env.FULL_MATRIX === 'true' || ci.fullMatrix === true,
- eval: ci.eval ?? false,
- eval_focused: ci.evalFocused ?? false,
- dashboard: ci.dashboard ?? false,
- frontend: ci.frontend ?? false,
- server: ci.server ?? false,
- agent_os: ci.agentOs ?? false,
- build_release: ci.buildRelease ?? false,
- open_notebook: ci.openNotebook ?? false,
- compose: ci.compose ?? false,
- desktop: ci.desktop ?? false,
- build: ci.build ?? false,
- full_unit: ci.fullUnit ?? false,
- integration: ci.integration ?? 'none',
- }
- appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(output)
- .map(([key, value]) => `${key}=${value}`)
- .join('\n') + '\n')
- NODE
- cat change-plan.json
-
- static:
- name: Static, unit, build and Agent Eval
- needs: classify
- runs-on: ubuntu-latest
- timeout-minutes: 12
- services:
- redis:
- image: redis:7-alpine
- ports:
- - 6379:6379
- options: >-
- --health-cmd "redis-cli ping"
- --health-interval 5s
- --health-timeout 5s
- --health-retries 12
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- - uses: actions/setup-python@v5
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.open_notebook == 'true'
- with:
- python-version: "3.12"
- - run: npm ci
- - name: Verify vendored Open Notebook scope contract
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.open_notebook == 'true'
- working-directory: third_party/open-notebook
- run: |
- python -m pip install pytest python-dotenv
- python -m pytest -q tests/test_lingxiloop_native_scope.py
- - name: Verify brand contract
- run: npm run guard:brand
- - name: Verify Agent OS architecture
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.agent_os == 'true' || needs.classify.outputs.eval == 'true'
- run: npm run guard:agent-os
- - name: Verify universal LLM ledger
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.server == 'true' || needs.classify.outputs.agent_os == 'true' || needs.classify.outputs.eval == 'true'
- run: npm run guard:llm-tracked
- - name: Verify synchronized version
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.build_release == 'true'
- run: npm run version:check
- - name: Lint changed code domains
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.frontend == 'true' || needs.classify.outputs.server == 'true' || needs.classify.outputs.eval == 'true' || needs.classify.outputs.build_release == 'true'
- run: npm run lint
- - name: Typecheck frontend
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.frontend == 'true' || needs.classify.outputs.dashboard == 'true'
- run: npm run typecheck
- - name: Typecheck server
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.server == 'true' || needs.classify.outputs.eval == 'true'
- run: npm run server:typecheck
- - name: Full unit suite
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.full_unit == 'true'
- run: npm test
- - name: Focused Agent Eval unit tests
- if: needs.classify.outputs.full != 'true' && needs.classify.outputs.eval == 'true'
- run: npm run test:eval
- - name: Agent Eval harness and real runtime regression gates
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.eval == 'true'
- run: npm run eval:check
- - name: Upload Agent Eval reports
- if: always() && (needs.classify.outputs.full == 'true' || needs.classify.outputs.eval == 'true')
- uses: actions/upload-artifact@v4
- with:
- name: agent-eval-${{ github.run_attempt }}
- path: artifacts/eval-*.json
- if-no-files-found: error
- - name: Build changed frontend surface
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.build == 'true'
- run: npm run build
-
- server-integration:
- name: Server integration (focused when eligible)
- needs: classify
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.integration != 'none'
- runs-on: ubuntu-latest
- timeout-minutes: 20
- services:
- postgres:
- image: pgvector/pgvector:pg16
- env:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: lingxiloop_test
- ports:
- - 5432:5432
- options: >-
- --health-cmd "pg_isready -U postgres -d lingxiloop_test"
- --health-interval 5s
- --health-timeout 5s
- --health-retries 12
- redis:
- image: redis:7-alpine
- ports:
- - 6379:6379
- options: >-
- --health-cmd "redis-cli ping"
- --health-interval 5s
- --health-timeout 5s
- --health-retries 12
- env:
- INTEGRATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/lingxiloop_test
- DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/lingxiloop_test
- REDIS_URL: redis://127.0.0.1:6379
- DEEPSEEK_API_KEY: integration-test-key
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- - run: npm ci
- - name: Bootstrap v1 integration schema
- run: npm run db:bootstrap
- - name: Run selected integration scope
- shell: bash
- run: |
- if [[ "${{ needs.classify.outputs.full }}" == "true" || "${{ needs.classify.outputs.integration }}" == "full" ]]; then
- npm run test:integration
- else
- npm run test:integration:eval
- fi
-
- agent-os-compose-smoke:
- name: Web + Worker + Agent OS Compose smoke
- needs: classify
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.compose == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 45
- steps:
- - uses: actions/checkout@v4
- - name: Build and start isolated MVP test stack
- shell: bash
- run: |
- set -o pipefail
- compose=(docker compose -f docker-compose.mvp.ci.yml)
- if ! "${compose[@]}" up -d --build --wait; then
- echo "First Compose startup failed; collecting state before one clean retry."
- "${compose[@]}" ps --all || true
- "${compose[@]}" logs --no-color --tail 200 || true
- "${compose[@]}" down -v --remove-orphans || true
- "${compose[@]}" up -d --build --wait
- fi
- - name: Verify WuKong webhook, durable work, IPython and final reply
- run: npm run mvp:ci:smoke
- - name: Collect service logs after failure
- if: failure()
- run: docker compose -f docker-compose.mvp.ci.yml logs --no-color --tail 300
- - name: Stop isolated MVP test stack
- if: always()
- run: docker compose -f docker-compose.mvp.ci.yml down -v
-
- desktop-directory-smoke:
- name: Desktop layout (${{ matrix.os }})
- needs: classify
- if: needs.classify.outputs.full == 'true' || needs.classify.outputs.desktop == 'true'
- strategy:
- fail-fast: false
- matrix:
- include:
- - os: windows-latest
- target: win
- - os: macos-latest
- target: mac
- runs-on: ${{ matrix.os }}
- timeout-minutes: 15
- env:
- CSC_IDENTITY_AUTO_DISCOVERY: "false"
- VITE_LINGXILOOP_API_BASE: https://loop.example.com
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- - run: npm ci
- - run: npm run build
- - run: npm run electron:prepare
- - name: Package unpacked Electron directory
- run: npx electron-builder --dir --${{ matrix.target }} --x64 --publish never
- - name: Verify Electron file layout
- run: node scripts/verify-desktop-package.mjs release
diff --git a/.github/workflows/black-box-eval.yml b/.github/workflows/black-box-eval.yml
new file mode 100644
index 00000000..9f641643
--- /dev/null
+++ b/.github/workflows/black-box-eval.yml
@@ -0,0 +1,92 @@
+name: Black-box Eval live report
+on:
+ pull_request:
+ types: [labeled]
+ workflow_dispatch:
+ inputs:
+ baseline_file:
+ description: Optional reviewed baseline JSON; without it the release gate remains blocked
+ required: false
+ default: ''
+ type: string
+ workflow_call:
+ inputs:
+ baseline_file:
+ required: false
+ default: ''
+ type: string
+ secrets:
+ EVAL_CANDIDATE_API_KEY:
+ required: true
+ EVAL_JUDGE_API_KEY:
+ required: true
+
+concurrency:
+ group: black-box-live-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+jobs:
+ release-gate:
+ if: github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.event.label.name == 'run-live-eval')
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: eval/package-lock.json
+ - run: npm ci --prefix eval
+ - run: npm --prefix eval run check && npm --prefix eval test
+ - name: Evaluate production Candidate with independent Judge
+ working-directory: eval
+ shell: bash
+ run: |
+ args=(run --suite suites/ci-smoke.v1.json --dataset datasets/ci-smoke.v1.json --revision "$GITHUB_SHA")
+ if [[ -n "$BASELINE_FILE" ]]; then args+=(--baseline-file "../$BASELINE_FILE"); fi
+ npm run eval -- "${args[@]}"
+ env:
+ BASELINE_FILE: ${{ inputs.baseline_file || vars.EVAL_BASELINE_FILE }}
+ EVAL_CANDIDATE_BASE_URL: ${{ vars.EVAL_CANDIDATE_BASE_URL }}
+ EVAL_CANDIDATE_MODEL: ${{ vars.EVAL_CANDIDATE_MODEL }}
+ EVAL_CANDIDATE_API_KEY: ${{ secrets.EVAL_CANDIDATE_API_KEY }}
+ EVAL_CANDIDATE_INPUT_CNY_PER_MILLION: ${{ vars.EVAL_CANDIDATE_INPUT_CNY_PER_MILLION }}
+ EVAL_CANDIDATE_OUTPUT_CNY_PER_MILLION: ${{ vars.EVAL_CANDIDATE_OUTPUT_CNY_PER_MILLION }}
+ EVAL_CANDIDATE_MAX_TOKENS: ${{ vars.EVAL_CANDIDATE_MAX_TOKENS }}
+ EVAL_CANDIDATE_TIMEOUT_MS: ${{ vars.EVAL_CANDIDATE_TIMEOUT_MS }}
+ EVAL_JUDGE_BASE_URL: ${{ vars.EVAL_JUDGE_BASE_URL }}
+ EVAL_JUDGE_MODEL: ${{ vars.EVAL_JUDGE_MODEL }}
+ EVAL_JUDGE_API_KEY: ${{ secrets.EVAL_JUDGE_API_KEY }}
+ EVAL_JUDGE_INPUT_CNY_PER_MILLION: ${{ vars.EVAL_JUDGE_INPUT_CNY_PER_MILLION }}
+ EVAL_JUDGE_OUTPUT_CNY_PER_MILLION: ${{ vars.EVAL_JUDGE_OUTPUT_CNY_PER_MILLION }}
+ EVAL_JUDGE_MAX_TOKENS: ${{ vars.EVAL_JUDGE_MAX_TOKENS }}
+ EVAL_JUDGE_TIMEOUT_MS: ${{ vars.EVAL_JUDGE_TIMEOUT_MS }}
+ - uses: actions/upload-artifact@v4
+ id: artifacts
+ if: always() && hashFiles('eval/.state/reports/*') != ''
+ with:
+ name: black-box-eval-${{ github.sha }}
+ path: eval/.state/reports/
+ retention-days: 30
+ - name: Publish visual report links and score summary
+ if: always()
+ shell: bash
+ env:
+ REPORT_URL: ${{ steps.artifacts.outputs.artifact-url }}
+ run: |
+ shopt -s nullglob
+ reports=(eval/.state/reports/*.md)
+ if (( ${#reports[@]} == 0 )); then
+ echo '## Black-box Eval: no report generated' >> "$GITHUB_STEP_SUMMARY"
+ echo 'Evaluation did not complete. Check the configuration and evaluation step; release remains blocked.' >> "$GITHUB_STEP_SUMMARY"
+ else
+ for report in "${reports[@]}"; do cat "$report" >> "$GITHUB_STEP_SUMMARY"; done
+ echo 'Costs use configured prices; zero prices produce zero estimates.' >> "$GITHUB_STEP_SUMMARY"
+ fi
+ if [[ -n "$REPORT_URL" ]]; then
+ echo "[Download visual HTML, JSON, Markdown and traces]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
+ echo 'Unzip the artifact and open the HTML file locally. No server or external assets are needed.' >> "$GITHUB_STEP_SUMMARY"
+ fi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eb454aa4..d47152e5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,4 @@
-name: LingxiLoop CI
+name: LingxiLoop scoped CI and CD
on:
pull_request:
@@ -6,79 +6,348 @@ on:
push:
branches: [main]
workflow_dispatch:
+ inputs:
+ scope:
+ description: Component to verify, or release to deploy all production projects
+ required: true
+ type: choice
+ options: [web, admin-control, server, agent-os, open-notebook, wukongim, gateway, deployment, release]
concurrency:
- group: lingxiloop-ci-${{ github.ref }}
+ group: lingxiloop-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
- packages: write
+ pull-requests: read
+
+env:
+ NODE_VERSION: "22"
jobs:
- quality:
- uses: ./.github/workflows/_quality.yml
+ changes:
+ runs-on: ubuntu-latest
+ outputs:
+ web: ${{ steps.scope.outputs.web }}
+ admin: ${{ steps.scope.outputs.admin }}
+ control: ${{ steps.scope.outputs.control }}
+ server: ${{ steps.scope.outputs.server }}
+ black_box_eval: ${{ steps.scope.outputs.black_box_eval }}
+ integration: ${{ steps.scope.outputs.integration }}
+ deploy_contract: ${{ steps.scope.outputs.deploy_contract }}
+ control_deploy: ${{ steps.scope.outputs.control_deploy }}
+ control_migrations: ${{ steps.scope.outputs.control_migrations }}
+ release: ${{ steps.scope.outputs.release }}
+ checks: ${{ steps.scope.outputs.checks }}
+ publish: ${{ steps.scope.outputs.publish }}
+ images: ${{ steps.scope.outputs.images }}
+ packages: ${{ steps.scope.outputs.packages }}
+ steps:
+ - uses: actions/checkout@v5
+ with: { fetch-depth: 0 }
+ - uses: dorny/paths-filter@v4
+ id: paths
+ with:
+ predicate-quantifier: some-with-excludes
+ filters: |
+ shared_frontend:
+ - 'package.json'
+ - 'package-lock.json'
+ - 'biome.json'
+ test_runner:
+ - 'scripts/run-tests.mjs'
+ web:
+ - 'src/**'
+ - 'public/**'
+ - 'index.html'
+ - 'vite.config.ts'
+ - 'tsconfig.json'
+ - 'tsconfig.node.json'
+ - 'postcss.config.js'
+ - 'tailwind.config.ts'
+ admin:
+ - 'admin/**'
+ - 'vite.admin.config.ts'
+ - 'tsconfig.admin.json'
+ - 'src/components/GlobalInteractionProvider.tsx'
+ - 'src/components/ResourceSkeleton.tsx'
+ - 'src/components/ui/**'
+ - 'src/lib/actionToast.ts'
+ - 'src/lib/confirmAction.ts'
+ control:
+ - 'workers/control-plane/**'
+ - 'worker-configuration.d.ts'
+ server:
+ - 'server/src/**'
+ - 'third_party/lingxios/**'
+ - 'server/scripts/**'
+ - 'server/package.json'
+ - 'server/package-lock.json'
+ - 'server/run-integration-tests.mjs'
+ - 'server/tsconfig.json'
+ - 'server/*.conf'
+ - 'server/docker/lingxiloop-server.Dockerfile'
+ - 'server/docker/lingxiloop-server.Dockerfile.dockerignore'
+ - 'server/docker/agent-os.Dockerfile'
+ server_source:
+ - 'server/src/**'
+ - 'third_party/lingxios/**'
+ - '!server/src/__tests__/**'
+ - '!server/src/__integration__/**'
+ - 'server/package.json'
+ - 'server/package-lock.json'
+ - 'server/*.conf'
+ server_docker:
+ - 'server/docker/lingxiloop-server.Dockerfile'
+ - 'server/docker/lingxiloop-server.Dockerfile.dockerignore'
+ agent_docker:
+ - 'server/docker/agent-os.Dockerfile'
+ black_box_eval:
+ - 'eval/**'
+ - 'docs/agent-eval.md'
+ open_notebook:
+ - 'third_party/open-notebook/Dockerfile'
+ - 'third_party/open-notebook/.dockerignore'
+ - 'third_party/open-notebook/pyproject.toml'
+ - 'third_party/open-notebook/uv.lock'
+ - 'third_party/open-notebook/rag_commands.py'
+ - 'third_party/open-notebook/supervisord.rag.conf'
+ - 'third_party/open-notebook/api/__init__.py'
+ - 'third_party/open-notebook/api/middleware.py'
+ - 'third_party/open-notebook/api/rag_*.py'
+ - 'third_party/open-notebook/open_notebook/__init__.py'
+ - 'third_party/open-notebook/open_notebook/config.py'
+ - 'third_party/open-notebook/open_notebook/exceptions.py'
+ - 'third_party/open-notebook/open_notebook/artifact_storage.py'
+ - 'third_party/open-notebook/open_notebook/database/**'
+ - 'third_party/open-notebook/open_notebook/domain/__init__.py'
+ - 'third_party/open-notebook/open_notebook/domain/base.py'
+ - 'third_party/open-notebook/open_notebook/rag/**'
+ - 'third_party/open-notebook/open_notebook/utils/__init__.py'
+ - 'third_party/open-notebook/open_notebook/utils/proxy.py'
+ - 'third_party/open-notebook/open_notebook/utils/url_validation.py'
+ wukongim:
+ - 'server/wukongim/**'
+ - 'server/docker/wukongim.Dockerfile'
+ gateway:
+ - 'deploy/openship/gateway.conf'
+ - 'deploy/openship/gateway.Dockerfile'
+ - 'website/**'
+ deployment:
+ - '.github/workflows/ci.yml'
+ - 'deploy/**'
+ - 'scripts/update-deployment-images.mjs'
+ - 'scripts/ci-scope.mjs'
+ - 'scripts/production-rag-contract.test.mjs'
+ control_migrations:
+ - 'workers/control-plane/migrations/**'
+ release:
+ - 'VERSION'
+ - id: scope
+ shell: bash
+ env:
+ EVENT: ${{ github.event_name }}
+ MANUAL_SCOPE: ${{ inputs.scope }}
+ SHARED_FRONTEND: ${{ steps.paths.outputs.shared_frontend }}
+ TEST_RUNNER: ${{ steps.paths.outputs.test_runner }}
+ WEB: ${{ steps.paths.outputs.web }}
+ ADMIN: ${{ steps.paths.outputs.admin }}
+ CONTROL: ${{ steps.paths.outputs.control }}
+ SERVER: ${{ steps.paths.outputs.server }}
+ SERVER_SOURCE: ${{ steps.paths.outputs.server_source }}
+ SERVER_DOCKER: ${{ steps.paths.outputs.server_docker }}
+ AGENT_DOCKER: ${{ steps.paths.outputs.agent_docker }}
+ BLACK_BOX_EVAL: ${{ steps.paths.outputs.black_box_eval }}
+ OPEN_NOTEBOOK: ${{ steps.paths.outputs.open_notebook }}
+ WUKONGIM: ${{ steps.paths.outputs.wukongim }}
+ GATEWAY: ${{ steps.paths.outputs.gateway }}
+ DEPLOYMENT: ${{ steps.paths.outputs.deployment }}
+ CONTROL_MIGRATIONS: ${{ steps.paths.outputs.control_migrations }}
+ RELEASE: ${{ steps.paths.outputs.release }}
+ run: node scripts/ci-scope.mjs
+
+ checks:
+ needs: changes
+ if: needs.changes.outputs.checks == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ services:
+ redis:
+ image: redis:7-alpine
+ ports: ["6379:6379"]
+ options: --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 12
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with: { node-version: "22", cache: npm }
+ - if: needs.changes.outputs.web == 'true' || needs.changes.outputs.admin == 'true' || needs.changes.outputs.control == 'true' || needs.changes.outputs.server == 'true'
+ run: npm ci
+ - if: needs.changes.outputs.web == 'true'
+ run: npm run lint && npm test && npm run build
+ - if: needs.changes.outputs.admin == 'true'
+ run: npm run admin:lint && npm run admin:test && npm run admin:build
+ - if: needs.changes.outputs.server == 'true'
+ run: npm ci --prefix third_party/lingxios && npm --prefix third_party/lingxios run typecheck && npm --prefix third_party/lingxios test
+ - if: needs.changes.outputs.server == 'true'
+ run: npm run server:lint && npm run server:typecheck && npm run server:test
+ - if: needs.changes.outputs.control == 'true'
+ run: npm run control:lint && npm run control:typecheck && npm run control:test
+ - if: needs.changes.outputs.black_box_eval == 'true'
+ run: npm ci --prefix eval && npm run eval:check
+ - if: needs.changes.outputs.deploy_contract == 'true'
+ run: node --test scripts/production-rag-contract.test.mjs
+ - if: needs.changes.outputs.release == 'true'
+ run: npm run version:check
+
+ integration:
+ needs: changes
+ if: needs.changes.outputs.integration == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ services:
+ postgres:
+ image: pgvector/pgvector:pg16
+ env: { POSTGRES_USER: postgres, POSTGRES_PASSWORD: postgres, POSTGRES_DB: lingxiloop_test }
+ ports: ["5432:5432"]
+ options: --health-cmd "pg_isready -U postgres -d lingxiloop_test" --health-interval 5s --health-timeout 5s --health-retries 12
+ redis:
+ image: redis:7-alpine
+ ports: ["6379:6379"]
+ options: --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 12
+ env:
+ INTEGRATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/lingxiloop_test
+ DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/lingxiloop_test
+ REDIS_URL: redis://127.0.0.1:6379
+ OPENAI_API_KEY: integration-test-key
+ OPENAI_EMBEDDING_MODEL: text-embedding-3-small
+ LINGXILOOP_INVITE_BASE_URL: http://127.0.0.1:5180
+ LINGXILOOP_GATEWAY_HMAC_SECRET: integration-gateway-secret
+ WUKONG_USER_TOKEN_SECRET: integration-wukong-user-token-secret
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with: { node-version: "22", cache: npm }
+ - run: npm ci
+ - run: npm run db:migrate
+ - run: npm run test:integration
- publish-packages:
- name: Publish ${{ matrix.package }} package
- if: github.event_name != 'pull_request'
- needs: quality
+ publish:
+ if: >-
+ always() &&
+ github.ref == 'refs/heads/main' && (github.event_name == 'push' || needs.changes.outputs.release == 'true') &&
+ needs.changes.outputs.publish == 'true' &&
+ (needs.checks.result == 'success' || needs.checks.result == 'skipped') &&
+ (needs.integration.result == 'success' || needs.integration.result == 'skipped')
+ needs: [changes, checks, integration]
runs-on: ubuntu-latest
timeout-minutes: 60
+ permissions: { contents: read, packages: write }
strategy:
fail-fast: false
- matrix:
- include:
- - package: lingxiloop-server
- dockerfile: server/docker/lingxiloop-server.Dockerfile
- wukong_commit: ""
- context: .
- - package: lingxiloop-agent-os
- dockerfile: server/docker/agent-os.Dockerfile
- wukong_commit: ""
- context: .
- - package: lingxiloop-wukongim
- dockerfile: server/docker/wukongim.Dockerfile
- wukong_commit: c7f663fa23a4ee2c6f7e08c68423f50f0f6e9c47
- context: .
- - package: lingxiloop-open-notebook
- # `file` is evaluated from the checked-out workspace by Buildx.
- # Keep the narrower context: the Dockerfile's COPY paths are
- # relative to this directory.
- dockerfile: third_party/open-notebook/Dockerfile
- wukong_commit: ""
- context: ./third_party/open-notebook
+ matrix: ${{ fromJSON(needs.changes.outputs.images) }}
steps:
- - uses: actions/checkout@v4
- - id: version
- name: Read package version
- shell: bash
- run: echo "version=$(tr -d '[:space:]' < VERSION)" >> "$GITHUB_OUTPUT"
- - uses: docker/setup-qemu-action@v3
+ - uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
+ - id: metadata
+ shell: bash
+ run: |
+ echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
+ echo "version=$(tr -d '[:space:]' < VERSION)" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build and publish immutable and MVP tags
- uses: docker/build-push-action@v6
+ with: { registry: ghcr.io, username: "${{ github.actor }}", password: "${{ secrets.GITHUB_TOKEN }}" }
+ - uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
- platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+ platforms: linux/amd64
push: true
- tags: |
- ghcr.io/lingxi-org/${{ matrix.package }}:mvp
- ghcr.io/lingxi-org/${{ matrix.package }}:${{ github.sha }}
- ghcr.io/lingxi-org/${{ matrix.package }}:${{ steps.version.outputs.version }}
- labels: |
- org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
- org.opencontainers.image.revision=${{ github.sha }}
- org.opencontainers.image.version=${{ steps.version.outputs.version }}
+ tags: ghcr.io/${{ steps.metadata.outputs.owner }}/${{ matrix.package }}:${{ github.sha }}
build-args: |
- LINGXILOOP_VERSION=${{ steps.version.outputs.version }}
+ LINGXILOOP_VERSION=${{ steps.metadata.outputs.version }}
LINGXILOOP_COMMIT_SHA=${{ github.sha }}
WUKONG_COMMIT=${{ matrix.wukong_commit }}
+ VITE_TURNSTILE_SITE_KEY=0x4AAAAAAEk9EZhHYeS3szPO
cache-from: type=gha,scope=${{ matrix.package }}
cache-to: type=gha,mode=max,scope=${{ matrix.package }}
+
+ update-manifests:
+ if: >-
+ always() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || needs.changes.outputs.release == 'true') &&
+ (needs.publish.result == 'success' ||
+ (needs.changes.outputs.publish == 'false' && needs.changes.outputs.deploy_contract == 'true' && needs.checks.result == 'success'))
+ needs: [changes, checks, publish]
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions: { contents: write }
+ outputs:
+ commit-sha: ${{ steps.pin.outputs.commit-sha }}
+ steps:
+ - uses: actions/checkout@v5
+ with: { ref: main, fetch-depth: 0 }
+ - name: Pin images published by this change
+ id: pin
+ run: |
+ git pull --rebase origin main
+ if [ "${{ needs.changes.outputs.publish }}" = "true" ]; then
+ node scripts/update-deployment-images.mjs "$GITHUB_SHA" ${{ needs.changes.outputs.packages }}
+ fi
+ if ! git diff --quiet; then
+ git config user.name github-actions[bot]
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ git add deploy/openship/*.yml
+ git commit -m "deploy: pin images to ${GITHUB_SHA} [skip ci]"
+ git push origin HEAD:main
+ fi
+ echo "commit-sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+
+ deploy:
+ if: >-
+ always() &&
+ github.event_name == 'push' && github.ref == 'refs/heads/main' &&
+ needs.changes.outputs.control_deploy == 'true' && needs.checks.result == 'success'
+ needs: [changes, checks]
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ environment: production
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with: { node-version: "22", cache: npm }
+ - run: npm ci
+ - if: needs.changes.outputs.control_migrations == 'true'
+ name: Apply D1 migrations
+ run: npm run control:d1:remote
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
+ - name: Deploy Refine and control-plane Worker
+ run: |
+ npm run admin:build
+ npx wrangler versions upload --config workers/control-plane/wrangler.jsonc --tag "$GITHUB_SHA"
+ npx wrangler versions deploy --config workers/control-plane/wrangler.jsonc --version-tag "$GITHUB_SHA" --yes
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
+ VITE_LINGXILIT_URL: ${{ vars.VITE_LINGXILIT_URL || 'https://openlit.lingxilearn.cn' }}
+ VITE_TURNSTILE_SITE_KEY: 0x4AAAAAAEk9EZhHYeS3szPO
+
+ rollout:
+ if: >-
+ always() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || needs.changes.outputs.release == 'true') &&
+ needs.update-manifests.result == 'success' &&
+ (needs.deploy.result == 'success' || needs.deploy.result == 'skipped')
+ needs: [update-manifests, deploy]
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ environment: production
+ steps:
+ - uses: actions/checkout@v5
+ with: { ref: main }
+ - name: Trigger OpenShip production rollout
+ run: node scripts/trigger-openship-release.mjs
+ env:
+ RELEASE_HMAC_SECRET: ${{ secrets.RELEASE_HMAC_SECRET }}
+ RELEASE_COMMIT_SHA: ${{ needs.update-manifests.outputs.commit-sha }}
+ RELEASE_DEPLOY_COMMIT_SHA: ${{ needs.update-manifests.outputs.commit-sha }}
+ RELEASE_REPOSITORY: ${{ github.repository }}
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
deleted file mode 100644
index 05f299cc..00000000
--- a/.github/workflows/desktop-release.yml
+++ /dev/null
@@ -1,216 +0,0 @@
-name: LingxiLoop Desktop release
-
-on:
- push:
- tags: ['v*']
- # A workflow-only change on main can repair/rebuild the tag matching
- # VERSION without deleting and recreating the tag. Path filters are not
- # evaluated for tag pushes, so normal v* releases still run as before.
- branches: [main]
- paths:
- - '.github/workflows/desktop-release.yml'
- workflow_dispatch:
- inputs:
- tag:
- description: Existing v* tag to build or rebuild
- required: true
- default: v0.1.0-beta
- type: string
-
-permissions:
- contents: write
-
-jobs:
- quality:
- uses: ./.github/workflows/_quality.yml
-
- resolve-release:
- name: Resolve desktop release target
- needs: quality
- runs-on: ubuntu-latest
- timeout-minutes: 10
- outputs:
- release_tag: ${{ steps.release.outputs.release_tag }}
- target_sha: ${{ steps.release.outputs.target_sha }}
- version: ${{ steps.release.outputs.version }}
- prerelease: ${{ steps.release.outputs.prerelease }}
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - id: release
- name: Resolve and validate tag
- env:
- REQUESTED_TAG: ${{ inputs.tag || '' }}
- shell: bash
- run: |
- set -euo pipefail
-
- git fetch origin main --tags --force
-
- if [[ "$GITHUB_REF_TYPE" == tag ]]; then
- TAG="$GITHUB_REF_NAME"
- elif [[ -n "$REQUESTED_TAG" ]]; then
- TAG="$REQUESTED_TAG"
- else
- MAIN_VERSION="$(tr -d '[:space:]' < VERSION)"
- TAG="v${MAIN_VERSION}"
- fi
-
- [[ "$TAG" == v* ]] || { echo "::error::desktop release tag must start with v"; exit 1; }
- git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null || {
- echo "::error::tag $TAG does not exist"
- exit 1
- }
-
- TARGET_SHA="$(git rev-list -n1 "$TAG")"
- VERSION="$(git show "$TARGET_SHA:VERSION" | tr -d '[:space:]')"
- EXPECTED="v${VERSION}"
- [[ "$TAG" == "$EXPECTED" ]] || {
- echo "::error::tag $TAG does not match VERSION $VERSION (expected $EXPECTED)"
- exit 1
- }
-
- git merge-base --is-ancestor "$TARGET_SHA" origin/main || {
- echo "::error::release tag commit $TARGET_SHA is not part of main"
- exit 1
- }
-
- git checkout --detach "$TARGET_SHA"
- node scripts/sync-version.mjs --check
-
- echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
- echo "target_sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- [[ "$VERSION" == *-* ]] && echo 'prerelease=true' >> "$GITHUB_OUTPUT" || echo 'prerelease=false' >> "$GITHUB_OUTPUT"
-
- echo "Desktop release target: $TAG -> $TARGET_SHA ($VERSION)"
-
- windows:
- name: Unsigned Windows x64
- needs: resolve-release
- runs-on: windows-latest
- timeout-minutes: 40
- env:
- VITE_LINGXILOOP_API_BASE: https://loop.lingxilearn.cn
- CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.resolve-release.outputs.target_sha }}
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- - run: npm ci
- - run: npm run build
- - run: npm run electron:prepare
- - run: npx electron-builder --win --x64 --publish never
- - run: node scripts/verify-desktop-package.mjs release
- - name: Verify installers are unsigned
- shell: pwsh
- run: |
- $installers = @(Get-ChildItem -LiteralPath release -Filter '*.exe' -File)
- if ($installers.Count -eq 0) { throw 'No Windows installer was produced' }
- foreach ($installer in $installers) {
- $signature = Get-AuthenticodeSignature -LiteralPath $installer.FullName
- if ($signature.Status -ne 'NotSigned') { throw "Expected unsigned installer: $($installer.Name) ($($signature.Status))" }
- }
- - uses: actions/upload-artifact@v4
- with:
- name: windows-release
- path: |
- release/*.exe
- release/*.blockmap
- release/latest.yml
- if-no-files-found: error
-
- macos:
- name: Unsigned macOS x64/arm64
- needs: resolve-release
- runs-on: macos-latest
- timeout-minutes: 60
- env:
- VITE_LINGXILOOP_API_BASE: https://loop.lingxilearn.cn
- CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.resolve-release.outputs.target_sha }}
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- - run: npm ci
- - run: npm run build
- - run: npm run electron:prepare
- - run: npx electron-builder --mac --x64 --arm64 --publish never
- - run: node scripts/verify-desktop-package.mjs release
- - name: Verify macOS release artifacts
- shell: bash
- run: |
- set -euo pipefail
- shopt -s nullglob
- dmgs=(release/*.dmg)
- zips=(release/*.zip)
- ((${#dmgs[@]} >= 2)) || { echo '::error::expected x64 and arm64 DMGs'; exit 1; }
- ((${#zips[@]} >= 2)) || { echo '::error::expected x64 and arm64 ZIPs'; exit 1; }
- test -f release/latest-mac.yml || { echo '::error::missing latest-mac.yml'; exit 1; }
- printf 'macOS artifacts:\n'
- printf ' %s\n' "${dmgs[@]}" "${zips[@]}" release/latest-mac.yml
- - uses: actions/upload-artifact@v4
- with:
- name: macos-release
- path: |
- release/*.dmg
- release/*.zip
- release/*.blockmap
- release/latest-mac.yml
- if-no-files-found: error
-
- publish:
- name: Publish GitHub Release
- needs: [resolve-release, windows, macos]
- runs-on: ubuntu-latest
- steps:
- - uses: actions/download-artifact@v4
- with:
- pattern: '*-release'
- path: artifacts
- merge-multiple: true
- - name: Verify complete release set
- shell: bash
- run: |
- set -euo pipefail
- find artifacts -maxdepth 1 -type f -print
- compgen -G 'artifacts/*.exe' >/dev/null
- compgen -G 'artifacts/*.dmg' >/dev/null
- compgen -G 'artifacts/*.zip' >/dev/null
- compgen -G 'artifacts/*.blockmap' >/dev/null
- test -f artifacts/latest.yml
- test -f artifacts/latest-mac.yml
- - name: Create or repair GitHub Release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- TAG: ${{ needs.resolve-release.outputs.release_tag }}
- TARGET_SHA: ${{ needs.resolve-release.outputs.target_sha }}
- PRERELEASE: ${{ needs.resolve-release.outputs.prerelease }}
- shell: bash
- run: |
- set -euo pipefail
-
- if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
- gh release upload "$TAG" artifacts/* --clobber --repo "$GITHUB_REPOSITORY"
- else
- args=(--repo "$GITHUB_REPOSITORY" --verify-tag --target "$TARGET_SHA" --title "LingxiLoop $TAG" --generate-notes)
- [[ "$PRERELEASE" == true ]] && args+=(--prerelease)
- gh release create "$TAG" artifacts/* "${args[@]}"
- fi
-
- if [[ "$PRERELEASE" == true ]]; then
- gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "LingxiLoop $TAG" --prerelease
- else
- gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "LingxiLoop $TAG" --latest
- fi
-
- echo "Published desktop assets for $TAG from $TARGET_SHA"
diff --git a/.gitignore b/.gitignore
index bef723bb..4f63cd11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,10 +22,16 @@ lerna-debug.log*
# Dependencies
node_modules
dist
+dist-admin
dist-ssr
/artifacts/eval-*.json
+/artifacts/.env*
+/artifacts/playwright/.playwright-artifacts-*/
+/artifacts/playwright/*/error-context.md
+/artifacts/playwright/*/trace.zip
# Python bytecode cache
+.venv/
__pycache__/
*.pyc
@@ -70,11 +76,8 @@ server/uploads/
.release.env
.release.next.env
.release.previous.env
+.openlit_apikey
*.tsbuildinfo
-# tsc emissions of vite.config.ts (build artifacts)
-vite.config.js
-vite.config.d.ts
-*.tsbuildinfo
docs/HANDOFF.md
android/app/google-services.json
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..afc9de7a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,45 @@
+# LingxiLoop repository rules
+
+LingxiLoop is a stable Web product. Preserve the current architecture unless the task explicitly changes it.
+
+## Supported surface
+
+- The browser Web app is the only supported release surface.
+- Electron is local-development compatibility only. Never add desktop publishing, auto-update, download entry points, CI runners, packaging tests, or desktop-specific product tests.
+- Every local Electron package command must pass `--publish never`.
+
+## Architecture
+
+- Keep Web/API and background worker entry points separate. Web processes must not start scheduled or queue workers.
+- Agent OS remains an independently deployable runtime. Its only model-visible tool is `ipython`; product effects cross the authenticated Host Bridge.
+- PostgreSQL owns product state, WuKongIM owns durable IM messages, Redis carries ephemeral coordination, and vendored Open Notebook/SurrealDB owns its independent knowledge schema.
+- All LLM calls use the shared server client and ledger. Preserve lease, retry, idempotency, message, and audit contracts.
+- Enforce tenant and project authorization server-side. Never trust client identifiers, expose secrets, log tokens, or weaken signed callback/webhook verification.
+- Preserve keyboard operation, visible focus, semantic labels, reduced-motion behavior, and readable contrast for Web UI changes.
+
+## PostgreSQL evolution
+
+- `server/src/db/migrations/0001_v1_baseline.sql` is immutable. Add one strictly increasing, descriptively named SQL migration for every schema change.
+- Never edit, rename, reorder, or delete an applied migration. Prefer forward-compatible expand/backfill/contract changes; migrations must be safe in their own transaction.
+- Runtime processes only call migration readiness checks and never execute DDL. Deployment runs `npm run db:migrate` before Web/Worker startup.
+- A non-empty database without `schema_migrations` is unsupported and must be rebuilt as an empty database by operations. Code must never auto-adopt or auto-delete it.
+- Open Notebook/SurrealDB keeps its separate vendored schema lifecycle.
+
+## Verification
+
+Run only the checks owned by the changed surface; never run repository-wide type checks, tests, builds, or deployment packaging:
+
+- Web: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`.
+- Admin/Control: the matching `admin:*` or `control:*` commands only.
+- Server: `npm run server:lint`, `npm run server:typecheck`, `npm run server:test`, plus only the owning integration files.
+- Agent Eval: run `eval:check` for the independent Eval package.
+- Vendored Open Notebook: run commands from its directory and only for its affected backend or frontend.
+
+Database migrations require the migration integration case and affected domain cases. Agent runtime changes require their owning integration cases; Eval must not import runtime internals.
+
+Playwright and browser-automation checks are forbidden in skills and tests. Do not add Playwright dependencies, configuration, snapshots, or test artifacts.
+
+## Production handoff
+
+- Before every final response, follow `.codex/hooks.json`. If the turn changed live production, update the applicable `operate-openship-production` references without recording secrets, then validate that skill.
+- OpenShip MCP authentication is supplied by the configured MCP transport. Never read, print, or copy its PAT into repository files or skills; validate access with a harmless MCP health call.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 484f1c7b..1043d6b0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,110 +1,34 @@
# Contributing to LingxiLoop
-Thanks for your interest in LingxiLoop. This guide covers how to get set up, the
-checks your change needs to pass, and a couple of architecture invariants that
-are enforced in CI so you don't get surprised.
+Contributions are licensed under the project [MIT License](LICENSE).
-By contributing you agree that your contributions are licensed under the
-project's [MIT License](LICENSE).
+## Setup
-## Getting set up
-
-You need **Node ≥ 20**, Python 3 with IPython, **Postgres**, **Redis**, and a
-WuKongIM v3 instance running locally.
+Use Node.js 22, Python 3 with IPython, PostgreSQL 16/pgvector, Redis 7, and a local WuKongIM v3 instance.
```bash
-createdb -h localhost lingxiloop
-export DEEPSEEK_API_KEY=sk-... # the only hard-required model credential
-
-npm install
-npm run db:bootstrap # initialize the immutable v1 schema once
-npm run dev:all # Vite renderer on :5180 + API server on :5181
+npm ci
+npm run db:migrate
+npm run dev:all
```
-Open http://localhost:5180 for the web app, or `npm run electron:dev` for the
-desktop shell. Web and worker processes never execute DDL: `db:bootstrap`
-accepts only an empty database or the already-marked v1 schema. Development
-databases from before v1 must be dropped and recreated. Startup seeds the v1
-schema with a starter team. Everything else (OAuth login, email, storage, the
-sub2api LLM gateway) soft-disables when its env vars are unset — see
-[`.env.example`](.env.example).
-
-Component-specific setup lives in [`docs/`](docs/), including desktop,
-deployment, and email integration notes.
-
-## Before you open a PR
-
-Run the same gates CI runs. All of these must pass:
-
-```bash
-npm run lint # Biome lint (autofix with `npm run lint:fix`)
-npm run typecheck # frontend types
-npm run server:typecheck # server types
-npm test # unit tests (node:test) for server + workers
-npm run test:integration # integration suite (needs local Postgres + Redis)
-npm run guard:agent-os # independent runtime/tool-boundary guard
-npm run guard:llm-tracked # architecture guard, see below
-```
-
-Biome is configured (`biome.json`) as a **linter only** — it is not a
-formatter here, so it won't reflow existing code. The rule set is a
-pragmatic subset of Biome's recommended rules: correctness and real-bug
-rules are on; noisy or intentional-pattern style rules (and the a11y
-group, tracked as separate follow-up work) are off.
-
-Both TypeScript projects are `strict`. There are no frontend unit tests yet;
-server and worker logic is covered by `server/src/__tests__` and
-`server/src/__integration__`.
-
-## Architecture invariants (enforced in CI)
-
-These are product boundaries, and a guard script fails the build if they are
-broken:
-
-1. **Agent OS is independent.** Runtime code must not call or install an agent
- CLI, and the model-facing tool list must contain only strict `ipython`.
- `npm run guard:agent-os` checks this boundary.
-2. **Every LLM call must be tracked** in the cost ledger. Untracked spend is a
- correctness bug here, not just an oversight. `npm run guard:llm-tracked`
- checks this.
-
-The learning-agent coordination model is documented in
-[`docs/COORDINATION.md`](docs/COORDINATION.md). Read it before changing routing,
-handoffs, the Agent OS loop or WuKong events.
-
-## Coding conventions
+Configure required providers through [`.env.example`](.env.example). Required capabilities fail closed; do not add fake production fallbacks.
-- Match the style of the file you're editing. The codebase leans on comments
- that explain *why* — constraints, trade-offs, and the history behind a
- non-obvious choice — not what the next line does. If your change reverses a
- decision a comment documents, update the comment.
-- Keep coordination prompts shape-level and minimal. Adding per-scenario examples to fix one
- observed bug is the most expensive class of change here — see the
- anti-patterns in `docs/COORDINATION.md`.
-- Prefer `any`-free, well-typed code; both tsconfigs are strict for a reason.
+The Web app is the only supported release surface. Electron is local-development compatibility only: do not add publishing, update, download, CI, or desktop-specific test paths. Local Electron builds must keep `--publish never`.
-## UI foundation
+## Make changes
-- `components.json` and the official shadcn registry are the only source for
- reusable UI primitives. Add or refresh one with
- `npx shadcn@latest add `.
-- Application and domain code imports primitives through
- `@/components/ui/*`. Radix implementation imports stay inside that directory;
- do not import Radix or Base UI directly from a feature.
-- Build application-specific fields and composites on those primitives instead
- of creating a second `Input`, `Select`, `Checkbox`, or other root primitive.
-- Use `lucide-react` for interface icons and `framer-motion` when JavaScript
- motion is necessary. Do not add another icon set or motion runtime.
+- Follow [`AGENTS.md`](AGENTS.md) and the existing module boundary.
+- Keep tenant/project authorization on the server and preserve audit, message, lease, idempotency, and LLM ledger contracts.
+- Add PostgreSQL changes as the next file under `server/src/db/migrations/`. Never edit, rename, reorder, or delete an applied migration.
+- Keep Open Notebook/SurrealDB schema work in its vendored lifecycle.
+- Match the edited file's style. Comments should explain non-obvious constraints, not restate code.
+- Use the canonical shadcn primitives and HugeIcons. Preserve keyboard access, focus, labels, reduced motion, and contrast.
-## Reporting bugs and security issues
+## Verify
-- **Security vulnerabilities**: do **not** file a public issue — follow
- [`SECURITY.md`](SECURITY.md).
-- **Bugs and features**: open a GitHub issue with clear reproduction steps and
- what you expected to happen.
+Run only the lint, typecheck, test, and build commands for the changed Web, Admin, Control, Server, or vendored Open Notebook surface. Use `test:integration -- --file ` for focused integration coverage and `eval:check` for the independent Eval package.
-## Commit and PR hygiene
+Migration changes require their migration and affected domain integration files. Agent behavior changes require the matching deterministic Eval gate. Browser verification is not part of repository validation or CI. CI classifies changed paths and skips unrelated checks, images, migrations, and deployments.
-- Write focused commits with a clear message explaining *why*, not just what.
-- Keep a PR to one logical change; smaller PRs get reviewed faster.
-- Make sure the full check list above is green before requesting review.
+Report security vulnerabilities through [`SECURITY.md`](SECURITY.md), not a public issue. Keep commits and pull requests focused on one logical change.
diff --git a/README.md b/README.md
index a6601cf0..67b61220 100644
--- a/README.md
+++ b/README.md
@@ -1,129 +1,59 @@
# LingxiLoop
-LingxiLoop is a learning collaboration product with its own Agent OS. Six
-specialized agents—Nova, Sage, Milo, Trace, Scout and Forge—work with learners
-in direct messages, Study Rooms and Labs.
+LingxiLoop is a Web learning-collaboration product with an independent Agent OS. Nova, Sage, Milo, Trace, Scout, and Forge work with learners in direct messages, Study Rooms, and Labs.
-The supported product surfaces are the Web app and the Electron desktop app
-for macOS, Windows, and Linux. Native iOS and Android apps are not maintained.
-
-The runtime is implemented in this repository. It does not invoke, install or
-pair with Codex, Claude, or another agent CLI. Codex Harness, Ankole and Prime
-Agent are architecture references only.
+The browser Web app is the only supported release surface. Electron remains available only for local development; it is never published, auto-updated, offered for download, or tested in CI.
## Architecture
```text
-Web / Electron
- ├─ WuKongIM v3 — messages, channels, ordering, membership, threads, read state
- └─ LingxiLoop Web — HTTP, WebSocket, webhook and online control-plane requests
-LingxiLoop Worker — schedulers, queue claims, retry, notification and GC
-Agent OS — stateless model loop, sessions, compaction, stop/steer
- └─ isolated persistent IPython kernel per Agent OS session
- └─ typed loop SDK → approved Host Bridge actions through LingxiLoop Web
+Browser Web ──> LingxiLoop Web/API ──> PostgreSQL / Redis / WuKongIM / Open Notebook
+ │
+LingxiLoop Worker ───────┘
+ │ authenticated Host Bridge
+Independent Agent OS ────┘
+ └─ isolated persistent IPython kernel per session
```
-The model receives exactly one tool:
-
-```ts
-{ name: "ipython", arguments: { code: string } }
-```
-
-Agent OS uses DeepSeek's OpenAI-compatible Chat Completions protocol and owns
-conversation history itself. `DEEPSEEK_BASE_URL` may point to an approved
-DeepSeek gateway, but there is no alternate provider registry. IPython variables survive across turns while
-the kernel lives; durable state must be written to Agent Home or a typed
-`loop.*` learning service. WuKongIM is the only authoritative message store.
+- WuKongIM is the authoritative durable message store.
+- PostgreSQL stores product state, Agent work, audit, and the append-only LLM ledger.
+- Redis carries ephemeral coordination.
+- Agent OS exposes exactly one model tool: `{ name: "ipython", arguments: { code: string } }`. Product effects use the authenticated Host Bridge.
+- Vendored Open Notebook/SurrealDB owns its independent knowledge schema lifecycle.
+- Web and Worker use the same server image but are independently scalable processes; Web never starts background jobs.
## Local development
-Requirements: Node.js 20+, Python 3 with IPython, PostgreSQL and Redis.
+Requirements: Node.js 22, Python 3 with IPython, PostgreSQL 16 with pgvector, and Redis 7.
```powershell
npm ci
-$env:DATABASE_URL = 'postgres://lingxiloop:lingxiloop@localhost:5432/lingxiloop'
-$env:REDIS_URL = 'redis://localhost:6379'
-$env:DEEPSEEK_API_KEY = '...'
-$env:DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1'
-$env:DEEPSEEK_MODEL = 'deepseek-chat'
-$env:AGENT_OS_SERVICE_TOKEN = 'replace-with-a-long-random-secret'
-npm run db:bootstrap
-npm run dev:all
-npm run agent-os:start
+Copy-Item .env.local.example .env.local
+# Fill the required database, Redis, OpenAI, WuKongIM, identity, and R2 values.
+npm run dev:migrate
+npm run dev:preview
```
-For the packaged MVP topology, copy `.env.example` to `.env`, provide the
-required secrets, and start the runtime:
+Open `http://localhost:5180`. For direct process development, run `npm run dev:all` and `npm run agent-os:start`. Electron can be run locally with `npm run electron:dev`; every package command is fixed to `--publish never`.
+
+PostgreSQL starts from [`0001_v1_baseline.sql`](server/src/db/migrations/0001_v1_baseline.sql) and evolves only through new numbered migrations. `npm run db:migrate` takes an advisory lock, verifies recorded names and checksums, and applies each pending file in its own transaction. It refuses a non-empty database without migration history; operations must rebuild such a legacy environment as an empty database. Web and Worker only verify that migrations are current.
+
+For the packaged service topology:
```powershell
+Copy-Item .env.example .env
+# Fill required product and secret values; image tags are managed by CI.
npm run mvp:up
```
-Compose pulls the `mvp` GHCR packages through
-`accel.way2api.fun/ghcr.io` by default—nothing is built locally—and waits for
-the v1 database bootstrap to complete before starting the LingxiLoop Web,
-background Worker, Agent OS and WuKongIM stack. Re-running Compose accepts the already-complete v1
-schema, while an unmarked pre-v1 or partial schema is rejected and must be
-dropped and recreated. API port
-5181 and WuKong WebSocket port 5200 bind to `0.0.0.0` by default for local and
-container networking; use TLS and override the bind addresses for public deployments.
-Packaged services default to warning-level, size-rotated logs. Canvas state is
-stored in Postgres and broadcast through the existing Redis/WebSocket path; no
-service mounts the Docker socket or shares an Agent execution environment.
+Compose runs the one-shot `db-migrate` service before Web and Worker.
## Verification
-```powershell
-npm run guard:agent-os
-npm run server:typecheck
-npm run typecheck
-npm test
-```
+Run only the commands for the changed surface: Web uses the unprefixed lint/typecheck/test/build commands; Admin, Control, and Server use their matching prefixes; Agent Eval uses `eval:check`; integration accepts owning files through `--file`.
+
+CI classifies changed paths, runs only their checks, and publishes only affected images to `ghcr.io//` with immutable commit-SHA tags. CI does not install or run a browser.
-The architecture guard rejects retired runtime files, executable Codex/Claude
-adapters, BYOA pairing configuration, LingxiGraph runtime dependencies and any
-model tool surface other than `ipython`.
-
-## Package publishing and production
-
-CI publishes `lingxiloop-server`, `lingxiloop-agent-os`,
-`lingxiloop-wukongim`, and the audited vendored `lingxiloop-open-notebook` as
-GHCR packages after every successful `main` build.
-Each receives immutable commit/version tags plus the rolling `mvp` tag used by
-the one-command deployment.
-
-[`docker-compose.production.yml`](docker-compose.production.yml) requires
-digest-pinned server, Agent OS, WuKongIM, and Open Notebook images. WuKongIM v3 source builds
-are pinned to commit `c7f663fa23a4ee2c6f7e08c68423f50f0f6e9c47`; production must deploy its
-verified immutable image digest. Its management API remains private, while the
-TLS client endpoint is published by the deployment proxy.
-
-LingxiLoop v1 intentionally has no database upgrade path. For PostgreSQL not
-managed by the supplied Compose files, initialize an empty database with
-`npm run db:bootstrap`; existing development databases must be dropped and
-recreated. Web and Worker startup never executes DDL. They run from the same
-immutable server image as independently restartable/scalable services; changing
-Web replica count never creates more scheduler, retry, sweeper or GC loops.
-
-## Repository map
-
-| Path | Purpose |
-| --- | --- |
-| `server/src/bin/` | Explicit Web, Worker and Agent OS process entrypoints |
-| `server/src/worker.ts` | Background task registry and documented concurrency policy |
-| `server/src/agent-os/` | Agent OS host, model loop, queue and Host Bridge contracts |
-| `server/agent-os/` | Persistent IPython kernel runner |
-| `server/src/im/` | WuKongIM bootstrap, webhook, routing and payload contracts |
-| `server/src/agents/` | Typed learning-domain services used by the Host Bridge |
-| `server/src/eval/` | Deterministic answer, RAG, tool, and multi-Agent evaluation pipeline |
-| `eval/suites/` + `eval/baselines/` | Versioned golden Eval datasets and merge-gating baselines |
-| `scripts/run-agent-eval.ts` | Frozen evaluator/harness self-test and baseline reporter |
-| `scripts/run-agent-runtime-eval.ts` | Deterministic current Agent OS runtime regression gate |
-| `src/lib/im/` | Browser-side WuKongIM SDK wrapper |
-| `src/admin/EvalPage.tsx` | Eval run pipeline, failure drill-down, and version comparison dashboard |
-| `.agents/skills/lingxiloop-eval-change/` | Eval suite, baseline, trace-safety, comparison, and focused-CI workflow |
-| `scripts/guard-agent-os.mjs` | CI guard for the independent runtime boundary |
-
-The Eval request contract and scoring rules are documented in [`docs/agent-eval.md`](docs/agent-eval.md).
+Production deployment and migration requirements are in [`docs/RELEASE.md`](docs/RELEASE.md). The current domain model is in [`docs/DOMAIN_MODEL.md`](docs/DOMAIN_MODEL.md), and Agent Eval is documented in [`docs/agent-eval.md`](docs/agent-eval.md).
Licensed under [MIT](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
index 227d2eaa..c0790195 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -67,7 +67,7 @@ If you self-host, at minimum:
`R2_*` variables) rather than the local-disk fallback, so a hostile upload
can never run on the app's origin.
- Keep every other secret (OAuth client secrets, `RESEND_API_KEY`,
- `EMAIL_INBOUND_HMAC_SECRET`, `R2_URL_SIGNING_SECRET`)
+ `RESEND_WEBHOOK_SECRET`, `R2_URL_SIGNING_SECRET`)
out of the repo and in your deployment's secret store.
See [`.env.example`](.env.example) for the full annotated list.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 6ff2f46a..5bb23aaf 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,5 +1,62 @@
# Third-Party Notices
+## Production topology references
+
+The Refine production overview adapts the 2.5D isometric canvas language from
+FossFLOW commit `59d51ec5a0be809522bc7b53cd70a50fc8dffbe6` and the read-only health-overlay
+model from Rackpad commit `05b75b85f3cd168bc95cd3ff8439a20d7c2cb04c`:
+
+- https://github.com/victortassinari/FossFLOW
+- https://github.com/Kobii-git/rackpad
+
+Both references are MIT licensed. No editor or React Flow runtime is bundled;
+the production map is implemented with the project's existing React, SVG and CSS.
+
+## Dokploy
+
+The Refine release dashboard adapts Dokploy's centralized deployment table
+component from commit `261ebb2317c324ae38f90bcacdd888ae06a04590`:
+https://github.com/Dokploy/dokploy/blob/261ebb2317c324ae38f90bcacdd888ae06a04590/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx
+
+The adapted source is retained in `admin/src/dokploy-deployment-board.tsx`
+under the Apache License, Version 2.0. Copyright 2026-present Dokploy
+Technology, Inc.
+
+## Kuma Mieru
+
+The Refine service-status dashboard adapts Kuma Mieru's
+`StatusBlockIndicator` and `MonitoringChart` components from commit
+`26a1ed33c1f5bfc77ba51fc61221a0c08dff2134`:
+https://github.com/Alice39s/kuma-mieru
+
+The adapted source is retained in `admin/src/kuma-mieru.tsx` and
+`admin/src/kuma-mieru-chart.tsx` under the Mozilla Public License, Version 2.0.
+The required source-form notice and license URL are preserved in both files.
+
+## Excalidraw Canvas Fonts
+
+LingxiLoop vendors the Assistant, Excalifont, and Xiaolai WOFF2 font assets
+used by its Canvas UI from Excalidraw commit
+`e1bb9ff8f8931e783c11d104abb8967ac6605c9a`:
+https://github.com/excalidraw/excalidraw/tree/e1bb9ff8f8931e783c11d104abb8967ac6605c9a/packages/excalidraw/fonts
+
+The font assets are stored under `src/features/canvas/fonts/Assistant`,
+`src/features/canvas/fonts/Excalifont`, and
+`src/features/canvas/fonts/Xiaolai`. Their local `@font-face` declarations
+are in `src/features/canvas/canvas-fonts.css`.
+
+All three font families are distributed under the SIL Open Font License,
+Version 1.1. The complete license text is retained at
+`src/features/canvas/fonts/OFL-1.1.txt`.
+
+## Bible Strong Avatar
+
+The dynamic LingxiLoop product avatar uses `@bible-strong/avatar-react` and
+`@bible-strong/avatar-core` version 0.1.0:
+https://github.com/smontlouis/bible-strong-avatar-lab
+
+AGPL-3.0-only, Copyright (c) Stéphane Montlouis-Calixte and contributors.
+
## Open Notebook
LingxiLoop's native knowledge engine vendors Open Notebook from commit
@@ -12,6 +69,40 @@ is retained at `third_party/open-notebook/LICENSE`.
MIT License, Copyright (c) 2024 Luis Novo.
+## LingxiOS
+
+LingxiLoop vendors the LingxiOS Agent OS source from commit
+`29aec516f92612e4860d372710e88f38d2e53528`:
+https://github.com/lyyzka/LingxiOS
+
+The source and its local integration notes are retained under
+`third_party/lingxios`. The upstream package metadata declares the MIT license;
+that snapshot did not include a standalone license file.
+
+## LingXi interactive-lecture-deck
+
+LingxiLoop's deterministic HTML lecture renderer is adapted from the visual,
+camera, spatial and interaction contracts in `interactive-lecture-deck`, pinned
+to commit `ca99f2227c4b35c918d294316ea5d0960c9d0f48`:
+https://github.com/LingXi-Org/LingxiSkills/tree/ca99f2227c4b35c918d294316ea5d0960c9d0f48/skills/interactive-lecture-deck
+
+The immutable upstream runtime snapshot and provenance are retained under
+`third_party/interactive-lecture-deck`. The adapted runtime also preserves the
+co-planarity fix from `a5802b9db011e414c0f616a11225564d59e9991a` and the
+slow-iframe race fix from `2973db3db5f1282cfbebad6da553a0d351c7a1b5`.
+
+MIT License, Copyright (c) 2026 LingXi Team. The complete license text is
+retained at `third_party/interactive-lecture-deck/LICENSE`.
+
+## OpenMAIC architecture reference
+
+The presentation research, planning, checkpoint, critic and targeted-repair
+stages were independently implemented with architectural reference to OpenMAIC
+at commit `dfebbcf33f3a56064129903faeab70a9e4243146`:
+https://github.com/THU-MAIC/OpenMAIC/tree/dfebbcf33f3a56064129903faeab70a9e4243146
+
+No OpenMAIC rendering DSL or rendering source code is included.
+
## OpenMausBot
Portions of the desktop conversation UX and visual system are inspired by
diff --git a/admin/index.html b/admin/index.html
new file mode 100644
index 00000000..92084e40
--- /dev/null
+++ b/admin/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ LingxiLoop 运营后台
+
+
+
+
+
+
diff --git a/admin/public/_headers b/admin/public/_headers
new file mode 100644
index 00000000..7917130e
--- /dev/null
+++ b/admin/public/_headers
@@ -0,0 +1,2 @@
+/assets/*
+ Cache-Control: public, max-age=31556952, immutable
diff --git a/admin/src/admin.css b/admin/src/admin.css
new file mode 100644
index 00000000..f7670d07
--- /dev/null
+++ b/admin/src/admin.css
@@ -0,0 +1,257 @@
+@import "../../src/styles/globals.css";
+@source "./**/*.{ts,tsx}";
+
+body {
+ margin: 0;
+ background: var(--background);
+ color: var(--foreground);
+}
+
+.admin-shell {
+ height: 100dvh;
+ min-height: 100dvh;
+ background: var(--sidebar);
+}
+
+.admin-header {
+ z-index: 20;
+ display: flex;
+ min-height: 4rem;
+ flex: none;
+ align-items: center;
+ gap: 0.75rem;
+ border-block-end: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
+ background: color-mix(in srgb, var(--background) 94%, transparent);
+ padding-inline: clamp(1rem, 2vw, 1.5rem);
+ backdrop-filter: blur(18px) saturate(1.1);
+}
+
+.admin-header-copy {
+ min-width: 0;
+}
+
+.admin-header-copy p {
+ font-family: var(--font-heading);
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.admin-header-copy span {
+ display: block;
+ overflow: hidden;
+ color: var(--muted-foreground);
+ font-size: 0.75rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-global-search {
+ inline-size: min(30rem, 42vw);
+ margin-inline-start: auto;
+}
+
+.admin-role-badge {
+ height: 1.75rem;
+ background: color-mix(in srgb, var(--card) 86%, transparent);
+}
+
+.admin-content {
+ min-height: 0;
+ flex: 1;
+ overflow-y: auto;
+ background:
+ radial-gradient(circle at 92% 0%, color-mix(in srgb, var(--primary) 5%, transparent), transparent 26rem),
+ var(--background);
+ padding: clamp(1.25rem, 3vw, 3rem) clamp(1rem, 3vw, 3rem) calc(2rem + env(safe-area-inset-bottom));
+}
+
+.admin-content > * {
+ width: min(100%, 96rem);
+ margin-inline: auto;
+}
+
+.admin-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
+ gap: 1rem;
+}
+
+.admin-metric-card {
+ min-height: 9.5rem;
+ border: 1px solid color-mix(in srgb, var(--border) 64%, transparent);
+ box-shadow: 0 10px 28px -22px color-mix(in srgb, var(--foreground) 35%, transparent);
+}
+
+.admin-metric-icon {
+ display: grid;
+ width: 2.5rem;
+ height: 2.5rem;
+ flex: none;
+ place-items: center;
+ border-radius: var(--radius-xl);
+ background: color-mix(in srgb, var(--primary) 11%, var(--background));
+ color: var(--primary);
+}
+
+.admin-metric-icon > svg {
+ width: 1.125rem;
+ height: 1.125rem;
+}
+
+.admin-metric-icon-destructive {
+ background: color-mix(in srgb, var(--destructive) 10%, var(--background));
+ color: var(--destructive);
+}
+
+.admin-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.admin-table-card {
+ gap: 0;
+ overflow: hidden;
+ padding-block: 0;
+ border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
+ box-shadow: 0 10px 28px -24px color-mix(in srgb, var(--foreground) 32%, transparent);
+}
+
+.admin-table-card [data-slot="table-header"] {
+ background: color-mix(in srgb, var(--muted) 72%, var(--card));
+}
+
+.admin-table-card [data-slot="table-head"] {
+ height: 2.75rem;
+ color: var(--muted-foreground);
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.admin-table-card [data-slot="table-cell"]:not(:last-child) {
+ min-width: 8rem;
+}
+
+.admin-cell-value {
+ display: -webkit-box;
+ overflow: hidden;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ word-break: normal;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+}
+
+.admin-page-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1.5rem;
+}
+
+.admin-page-heading h1 {
+ font-family: var(--font-heading);
+ font-size: clamp(1.65rem, 3vw, 2.25rem);
+ font-weight: 620;
+ letter-spacing: -0.035em;
+ line-height: 1.15;
+}
+
+.admin-page-heading p {
+ max-width: 48rem;
+ margin-block-start: 0.5rem;
+ color: var(--muted-foreground);
+ font-size: 0.875rem;
+}
+
+.admin-heading-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.admin-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
+ gap: 1rem;
+}
+
+.admin-status-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
+ gap: 1rem;
+ align-items: start;
+}
+
+.admin-detail-field {
+ min-width: 0;
+ border: 1px solid color-mix(in srgb, var(--border) 68%, transparent);
+ box-shadow: 0 8px 22px -22px color-mix(in srgb, var(--foreground) 30%, transparent);
+}
+
+.admin-detail-field pre,
+.admin-json {
+ max-height: 36rem;
+ overflow: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.75rem;
+ line-height: 1.65;
+}
+
+.admin-state {
+ min-height: 18rem;
+ border: 1px dashed var(--border);
+ background: color-mix(in srgb, var(--card) 76%, transparent);
+}
+
+@media (max-width: 64rem) {
+ .admin-role-badge {
+ display: none;
+ }
+
+ .admin-global-search {
+ inline-size: min(26rem, 52vw);
+ }
+}
+
+@media (max-width: 42rem) {
+ .admin-header {
+ padding-inline: 0.75rem;
+ }
+
+ .admin-header-copy {
+ display: none;
+ }
+
+ .admin-global-search {
+ inline-size: auto;
+ flex: 1;
+ }
+
+ .admin-content {
+ padding-block-start: 1.25rem;
+ }
+
+ .admin-toolbar,
+ .admin-page-heading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .admin-heading-actions {
+ justify-content: flex-start;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .admin-shell *,
+ .admin-shell *::before,
+ .admin-shell *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/admin/src/api.ts b/admin/src/api.ts
new file mode 100644
index 00000000..ad82bcbf
--- /dev/null
+++ b/admin/src/api.ts
@@ -0,0 +1,125 @@
+import type {
+ AccessControlProvider,
+ AuthProvider,
+ BaseRecord,
+ CrudFilter,
+ CustomParams,
+ DataProvider,
+ GetListParams,
+ GetOneParams,
+ HttpError,
+} from '@refinedev/core'
+import { createAuthClient } from 'better-auth/react'
+
+export const API_URL = '/api'
+export const adminAuthClient = createAuthClient({ baseURL: location.origin, basePath: '/api/auth' })
+
+function httpError(statusCode: number, message: string): HttpError {
+ return { statusCode, message }
+}
+
+export async function adminFetch(path: string, init: RequestInit = {}): Promise {
+ const headers = new Headers(init.headers)
+ headers.set('content-type', 'application/json')
+ const response = await fetch(`${API_URL}${path}`, { ...init, credentials: 'include', headers })
+ if (!response.ok) {
+ const payload = await response.json().catch(() => null) as { error?: string } | null
+ throw httpError(response.status, payload?.error ?? `${response.status} ${response.statusText}`)
+ }
+ return response.json() as Promise
+}
+
+function logicalFilters(filters: CrudFilter[] | undefined): Record {
+ const result: Record = {}
+ for (const filter of filters ?? []) {
+ if ('field' in filter && filter.value !== undefined && filter.value !== '') {
+ result[String(filter.field)] = String(filter.value)
+ }
+ }
+ return result
+}
+
+export const dataProvider: DataProvider = {
+ getApiUrl: () => API_URL,
+ getList: async (params: GetListParams) => {
+ const { resource, pagination, filters, sorters } = params
+ const pageSize = pagination?.pageSize ?? 50
+ const currentPage = pagination?.currentPage ?? 1
+ const parameters = new URLSearchParams({
+ limit: String(pageSize),
+ cursor: btoa(String((currentPage - 1) * pageSize)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''),
+ ...logicalFilters(filters),
+ })
+ const sorter = sorters?.[0]
+ if (sorter) parameters.set('sort', sorter.field === 'id' ? 'id' : sorter.order === 'asc' ? 'oldest' : 'newest')
+ const result = await adminFetch<{ data: TData[]; nextCursor: string | null; total?: number }>(
+ `/control/platform/resources/${encodeURIComponent(resource)}?${parameters}`,
+ )
+ return {
+ data: result.data,
+ total: result.total ?? ((currentPage - 1) * pageSize + result.data.length + (result.nextCursor ? 1 : 0)),
+ }
+ },
+ getOne: async (params: GetOneParams) => ({
+ data: await adminFetch(`/control/platform/resources/${encodeURIComponent(params.resource)}/${encodeURIComponent(params.id)}`),
+ }),
+ create: async () => { throw httpError(405, '该资源不支持任意创建') },
+ update: async () => { throw httpError(405, '该资源不支持任意编辑') },
+ deleteOne: async () => { throw httpError(405, '该资源不支持任意删除') },
+ custom: async (params: CustomParams) => ({
+ data: await adminFetch(params.url.replace(API_URL, ''), {
+ method: params.method.toUpperCase(),
+ body: params.payload === undefined ? undefined : JSON.stringify(params.payload),
+ headers: params.headers,
+ }),
+ }),
+}
+
+export const authProvider: AuthProvider = {
+ login: async ({ email, password }) => {
+ const result = await adminAuthClient.signIn.email({ email: String(email), password: String(password) })
+ return result.error ? { success: false, error: httpError(result.error.status ?? 401, result.error.message ?? '登录失败') } : { success: true, redirectTo: '/' }
+ },
+ register: async ({ email, password, name, inviteToken, inviteKind }) => {
+ const response = await fetch('/api/auth/sign-up/email', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password, name, inviteToken, inviteKind }) })
+ const payload = await response.json().catch(() => ({})) as { error?: string }
+ return response.ok ? { success: true, redirectTo: '/login' } : { success: false, error: httpError(response.status, payload.error ?? '注册失败') }
+ },
+ logout: async () => {
+ await adminAuthClient.signOut()
+ return { success: true, redirectTo: '/login' }
+ },
+ check: async () => {
+ const session = await adminAuthClient.getSession()
+ if (!session.data) return { authenticated: false, redirectTo: '/login' }
+ const role = (session.data.user as { role?: string }).role
+ return role === 'admin' ? { authenticated: true } : { authenticated: false, redirectTo: '/forbidden', logout: false }
+ },
+ getIdentity: async () => {
+ const session = await adminAuthClient.getSession()
+ return session.data?.user ?? null
+ },
+ onError: async (error) => {
+ const status = (error as HttpError).statusCode
+ if (status === 401) return { logout: true, redirectTo: '/login', error }
+ if (status === 403) return { redirectTo: '/forbidden', error }
+ return { error }
+ },
+}
+
+export const accessControlProvider: AccessControlProvider = {
+ can: async ({ resource, action }) => {
+ const session = await adminAuthClient.getSession()
+ if ((session.data?.user as { role?: string } | undefined)?.role !== 'admin') return { can: false, reason: '需要 D1 管理员角色' }
+ if (action === 'list' || action === 'show') return { can: true }
+ const commands: Record = {
+ users: ['suspend', 'restore', 'delete'],
+ companies: ['activate', 'enter-read-only', 'archive'],
+ projects: ['activate', 'end', 'enter-read-only', 'archive'],
+ 'agent-routines': ['pause'],
+ }
+ if (resource && commands[resource]?.includes(action)) return { can: true }
+ return { can: false, reason: '平台后台仅开放明确的业务命令' }
+ },
+ options: { buttons: { enableAccessControl: true, hideIfUnauthorized: true } },
+}
diff --git a/admin/src/app.tsx b/admin/src/app.tsx
new file mode 100644
index 00000000..0c7522de
--- /dev/null
+++ b/admin/src/app.tsx
@@ -0,0 +1,47 @@
+import { Authenticated, Refine } from '@refinedev/core'
+import routerProvider, { CatchAllNavigate, NavigateToResource } from '@refinedev/react-router'
+import { lazy, type ReactNode, Suspense } from 'react'
+import { BrowserRouter, Outlet, Route, Routes } from 'react-router'
+import { accessControlProvider, authProvider, dataProvider } from './api'
+import { AuthSettingsPage } from './auth-settings-page'
+import { AdminLayout, ForbiddenPage, LoginPage, ResourceDetailPage, ResourceListPage, SearchPage } from './pages'
+import { ProductionTopologyPage } from './production-topology-page'
+import { ADMIN_RESOURCES } from './resources'
+
+const ReleaseManagementPage = lazy(() => import('./release-management-page').then((module) => ({ default: module.ReleaseManagementPage })))
+const ServiceStatusPage = lazy(() => import('./status-page').then((module) => ({ default: module.ServiceStatusPage })))
+const ObservabilityPage = lazy(() => import('./observability-page').then((module) => ({ default: module.ObservabilityPage })))
+const deferredPage = (page: ReactNode) => 正在加载页面…