7 Commits

28 changed files with 1269 additions and 552 deletions

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
mail@adityagupta.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

99
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,99 @@
# Contributing to Omnia
Thank you for your interest in contributing to Omnia! We welcome contributions from developers, technical writers, and anyone interested in agentic narrative simulation.
Please take a moment to review this document before submitting contributions.
## Table of Contents
1. [Documentation](#documentation)
2. [Getting Started](#getting-started)
3. [Development Workflow](#development-workflow)
4. [Coding Standards](#coding-standards)
5. [Pull Request Guidelines](#pull-request-guidelines)
## Documentation
The primary source of truth for the Omnia project is the official documentation:
👉 **[Omnia Documentation](https://omnia.adityagupta.dev/docs)**
Please refer to the documentation to understand the project architecture, memory model, spatial systems, intents framework, and custom LLM configurations.
## Getting Started
Omnia is organized as a monorepo managed with **pnpm** workspaces.
### Prerequisites
- **Node.js** (v22.13 or newer recommended)
- **pnpm** (v11 or newer recommended)
### Local Setup
1. Fork the repository and clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/omnia-consolidated.git
cd omnia-consolidated
```
2. Install dependencies:
```bash
pnpm install
```
3. Run the Web GUI interface locally:
```bash
pnpm dev:gui
```
4. Run the Starlight documentation site locally:
```bash
pnpm dev:docs
```
## Development Workflow
### Branching
Create a descriptive branch for your changes:
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/issue-description
```
### Running Tests
Make sure all unit tests pass before submitting changes:
```bash
# Run tests once
pnpm test
# Run tests in watch mode
pnpm test:watch
```
### Linting and Formatting
We enforce consistent code quality and formatting rules across the repository.
```bash
# Check code style and formatting
pnpm lint
pnpm format:check
# Auto-fix code style issues
pnpm lint:fix
pnpm format
```
## Coding Standards
- **TypeScript**: Omnia is written entirely in TypeScript. Ensure all new code is strongly typed.
- **Docstrings**: Document public-facing APIs, methods, and configurations.
## Pull Request Guidelines
1. **Keep PRs Focused**: Keep your changes as small and focused as possible.
2. **Include Tests**: If you are introducing a new feature or fixing a bug, write corresponding tests in `tests/`.
3. **Update Documentation**: If your changes alter public behavior or introduce new APIs, update the docs under `web/docs/src/content/docs/`.
4. **Follow Commit Conventions**: Write clear, descriptive commit messages.

View File

@@ -38,7 +38,11 @@ Omnia answers every one of these failures with the same move: **pull the thing t
- **Actions:** Actions are proposals (Intents) that engine code validates and applies; they are never direct edits the model makes to the world. The model proposes; deterministic code disposes.
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know. The secret is not in its prompt, so there is **nothing to jailbreak out of it**.
## What This Buys You
## What this buys you
<p align="center">
<img src="./web/docs/src/assets/img/features.webp" />
</p>
The payoff is scenario complexity that **uni-agent systems structurally cannot represent, no matter how good the model gets**.

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -10,14 +10,14 @@
"lint": "next lint"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/actor": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/core": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/memory": "workspace:*",
"@omnia/spatial": "workspace:*",
"@omnia/scenario": "workspace:*",
"@omnia/spatial": "workspace:*",
"dotenv": "^17.4.2",
"next": "^16.2.10",
"react": "^19.2.0",
@@ -27,6 +27,9 @@
"@types/node": "^26.1.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3"
}
}

View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -10,8 +10,9 @@ import {
getProviderMappings,
setProviderMapping,
updateProviderInstance,
getAvailableProviders,
} from "@/app/play/actions";
import type { LLMProviderInstance } from "@omnia/llm";
import type { LLMProviderInstance, LLMProviderMeta } from "@omnia/llm";
interface ConfigStatus {
apiKeySet: boolean;
@@ -24,6 +25,7 @@ export default function ConfigPage() {
const [config, setConfig] = useState<ConfigStatus | null>(null);
const [instances, setInstances] = useState<LLMProviderInstance[]>([]);
const [mappings, setMappings] = useState<Record<string, string>>({});
const [availableProviders, setAvailableProviders] = useState<LLMProviderMeta[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -37,9 +39,11 @@ export default function ConfigPage() {
useEffect(() => {
if (selectedInstanceId === "new") {
setEditName("");
setEditProvider("google-genai");
const defaultProvider = "google-genai";
setEditProvider(defaultProvider);
setEditKey("");
setEditModel("gemini-2.5-flash");
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(false);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
@@ -47,11 +51,20 @@ export default function ConfigPage() {
setEditName(inst.name);
setEditProvider(inst.providerName);
setEditKey("");
setEditModel(inst.modelName || "gemini-2.5-flash");
const pMeta = availableProviders.find((p) => p.id === inst.providerName);
setEditModel(inst.modelName || pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(inst.isActive);
}
}
}, [selectedInstanceId, instances]);
}, [selectedInstanceId, instances, availableProviders]);
const handleProviderChange = (providerId: string) => {
setEditProvider(providerId);
const pMeta = availableProviders.find((p) => p.id === providerId);
if (pMeta) {
setEditModel(pMeta.defaultModel);
}
};
const loadInstances = useCallback(async () => {
try {
@@ -79,6 +92,8 @@ export default function ConfigPage() {
setConfig(result);
await loadInstances();
await loadMappings();
const provs = await getAvailableProviders();
setAvailableProviders(provs);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -159,44 +174,57 @@ export default function ConfigPage() {
};
return (
<div className="config-page">
<h1>Configuration</h1>
<div className="mx-auto max-w-[800px] px-4 py-8">
<h1 className="mb-6 text-2xl">Configuration</h1>
{loading && <p>Loading configuration...</p>}
{error && <div className="error-banner">{error}</div>}
{error && (
<div className="mb-4 rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{config && !loading && (
<>
<section className="config-section">
<h2>LLM Provider Instances</h2>
<div className="provider-split-container">
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
{/* 30% area */}
<div className="provider-list-pane">
<div className="pane-header">
<h3>Instances</h3>
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">Instances</h3>
<button
onClick={() => setSelectedInstanceId("new")}
className="btn-add-inst"
className="cursor-pointer rounded-md bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600"
type="button"
>
+ Add
</button>
</div>
<div className="pane-list">
<div className="flex flex-1 flex-col overflow-y-auto">
{instances.length === 0 ? (
<div className="no-instances-msg">No instances configured</div>
<div className="px-4 py-8 text-center text-xs text-gray-400">
No instances configured
</div>
) : (
instances.map((inst) => (
<div
key={inst.id}
onClick={() => setSelectedInstanceId(inst.id)}
className={`instance-list-item ${selectedInstanceId === inst.id ? "active" : ""}`}
className={`cursor-pointer border-b border-gray-200 border-l-[3px] px-4 py-4 transition-all hover:bg-gray-100 ${
selectedInstanceId === inst.id
? "border-l-blue-500 bg-blue-50"
: "border-l-transparent"
}`}
>
<div className="item-name">{inst.name}</div>
<div className="item-meta">
<div className="text-sm font-medium text-[#111]">{inst.name}</div>
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{inst.providerName}</span>
{inst.isActive && <span className="active-pill">Active</span>}
{inst.isActive && (
<span className="rounded-full bg-green-100 px-1.5 py-[1px] text-[0.65rem] font-semibold text-green-700">
Active
</span>
)}
</div>
</div>
))
@@ -205,17 +233,19 @@ export default function ConfigPage() {
</div>
{/* 70% area */}
<div className="provider-form-pane">
<form onSubmit={handleSave} className="provider-config-form">
<div className="form-scroll-content">
<h3>
<div className="flex flex-col bg-white">
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
<div className="flex flex-1 flex-col gap-5 p-6">
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
{selectedInstanceId === "new"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="form-group">
<label htmlFor="formName">Friendly Name</label>
<div className="flex flex-col gap-1.5">
<label htmlFor="formName" className="text-xs font-medium text-gray-700">
Friendly Name
</label>
<input
id="formName"
type="text"
@@ -223,23 +253,37 @@ export default function ConfigPage() {
onChange={(e) => setEditName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="form-group">
<label htmlFor="formProvider">Provider Type</label>
<div className="flex flex-col gap-1.5">
<label htmlFor="formProvider" className="text-xs font-medium text-gray-700">
Provider Type
</label>
<select
id="formProvider"
value={editProvider}
onChange={(e) => setEditProvider(e.target.value)}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
<option value="google-genai">Google Gemini (Gemini-2.5-flash)</option>
<option value="mock">Mock LLM Provider</option>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.displayName}
</option>
))}
</select>
{editProvider && availableProviders.length > 0 && (
<span className="mt-1 block rounded border border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-600">
{availableProviders.find((p) => p.id === editProvider)?.description}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="formKey">API Key</label>
<div className="flex flex-col gap-1.5">
<label htmlFor="formKey" className="text-xs font-medium text-gray-700">
API Key
</label>
<input
id="formKey"
type="password"
@@ -251,76 +295,92 @@ export default function ConfigPage() {
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="form-group">
<label htmlFor="formModel">Model Name</label>
<div className="flex flex-col gap-1.5">
<label htmlFor="formModel" className="text-xs font-medium text-gray-700">
Model Name
</label>
<input
id="formModel"
type="text"
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="form-group checkbox-group">
<div className="mt-1 flex flex-row items-center gap-2">
<input
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<label htmlFor="formActive">Set as Active Instance</label>
<label htmlFor="formActive" className="cursor-pointer text-xs font-medium text-gray-700">
Set as Active Instance
</label>
</div>
</div>
<div className="form-actions-bar">
<div className="action-left">
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="btn-delete-pane"
className="cursor-pointer rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
>
Delete
</button>
)}
</div>
<div className="action-right">
<button type="submit" disabled={loading} className="btn-save-pane">
<div>
<button
type="submit"
disabled={loading}
className="cursor-pointer rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save"}
</button>
</div>
</div>
</form>
</div>
</div>
</section>
<section className="config-section">
<h2>Task Provider Routing</h2>
<p className="config-hint" style={{ background: "#eff6ff", border: "1px solid #bfdbfe", color: "#1e3a8a", margin: "1rem 0" }}>
Configure which LLM Provider Key Instance should handle each specific simulation task. Mappings default to the currently <strong>Active</strong> instance if not specified.
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Task Provider Routing</h2>
<p className="my-4 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
Configure which LLM Provider Key Instance should handle each specific simulation
task. Mappings default to the currently <strong>Active</strong> instance if not
specified.
</p>
<div className="mappings-grid">
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{[
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters." },
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules." },
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC)." },
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock." },
].map((task) => (
<div key={task.key} className="mapping-card">
<div className="mapping-info">
<strong>{task.label}</strong>
<span className="text-gray" style={{ fontSize: "0.75rem", marginTop: "0.125rem" }}>{task.desc}</span>
<div
key={task.key}
className="flex flex-col justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div className="flex flex-col gap-1 text-xs">
<strong className="text-sm text-[#111]">{task.label}</strong>
<span className="mt-0.5 text-gray-500">{task.desc}</span>
</div>
<select
value={mappings[task.key] || ""}
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs"
>
<option value="">-- Use Active Key (Default) --</option>
{instances.map((inst) => (
@@ -334,21 +394,21 @@ export default function ConfigPage() {
</div>
</section>
<section className="config-section">
<h2>Environment Variables Default</h2>
<div className="config-row">
<span className="config-label">Default Model</span>
<span className="config-value">
<code>{config.model}</code>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Environment Variables Default</h2>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default Model</span>
<span className="text-sm">
<code className="font-mono text-sm">{config.model}</code>
</span>
</div>
<div className="config-row">
<span className="config-label">Default API Key (.env)</span>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default API Key (.env)</span>
<span
className={
config.apiKeySet
? "config-value status-ok"
: "config-value status-error"
? "text-sm text-green-600"
: "text-sm font-medium text-red-600"
}
>
{config.apiKeySet
@@ -358,26 +418,30 @@ export default function ConfigPage() {
</div>
</section>
<section className="config-section">
<h2>Available Scenarios</h2>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Available Scenarios</h2>
{config.availableScenarios.length === 0 ? (
<p className="config-hint">
No scenarios found in <code>content/demo/scenarios/</code>.
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
No scenarios found in <code className="font-mono text-xs">content/demo/scenarios/</code>.
</p>
) : (
<table className="scenario-table">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
Name
</th>
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
Path
</th>
</tr>
</thead>
<tbody>
{config.availableScenarios.map((s) => (
<tr key={s.path}>
<td>{s.name}</td>
<td>
<code>{s.path}</code>
<td className="border-b border-gray-100 p-2">{s.name}</td>
<td className="border-b border-gray-100 p-2">
<code className="font-mono text-xs text-blue-600">{s.path}</code>
</td>
</tr>
))}
@@ -386,392 +450,18 @@ export default function ConfigPage() {
)}
</section>
<section className="config-section">
<h2>Engine Packages</h2>
<p className="config-hint">
All <code>@omnia/*</code> workspace packages are consumed via{" "}
<code>transpilePackages</code> in <code>next.config.ts</code>.
The native <code>better-sqlite3</code> module is externalized via{" "}
<code>serverExternalPackages</code>.
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Engine Packages</h2>
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
All <code className="font-mono text-xs">@omnia/*</code> workspace packages are
consumed via <code className="font-mono text-xs">transpilePackages</code> in{" "}
<code className="font-mono text-xs">next.config.ts</code>. The native{" "}
<code className="font-mono text-xs">better-sqlite3</code> module is externalized
via <code className="font-mono text-xs">serverExternalPackages</code>.
</p>
</section>
</>
)}
<style>{`
.config-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.config-page h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.config-section {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.config-section h2 {
font-size: 1.125rem;
margin-bottom: 0.75rem;
}
.config-row {
display: flex;
justify-content: space-between;
padding: 0.375rem 0;
border-bottom: 1px solid #f3f4f6;
}
.config-label {
color: #555;
font-size: 0.875rem;
}
.config-value {
font-size: 0.875rem;
}
.status-ok {
color: #16a34a;
}
.status-error {
color: #dc2626;
font-weight: 500;
}
.config-hint {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
font-size: 0.8125rem;
color: #92400e;
}
.config-hint code {
background: rgba(0,0,0,0.06);
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-size: 0.75rem;
}
.error-banner {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fca5a5;
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.scenario-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.scenario-table th {
text-align: left;
padding: 0.5rem;
border-bottom: 2px solid #e5e7eb;
color: #555;
font-weight: 500;
}
.scenario-table td {
padding: 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.scenario-table code {
font-size: 0.8125rem;
color: #2563eb;
}
code {
font-family: monospace;
font-size: 0.875rem;
}
/* Pill and List Styles */
.status-pill {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.status-pill.active {
background: #dcfce7;
color: #15803d;
}
.status-pill.inactive {
background: #f3f4f6;
color: #4b5563;
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-sm:hover {
background: #2563eb;
}
.btn-sm.delete-btn {
background: #ef4444;
}
.btn-sm.delete-btn:hover {
background: #dc2626;
}
/* Split container */
.provider-split-container {
display: grid;
grid-template-columns: 1fr;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
background: #fff;
margin-top: 1rem;
min-height: 400px;
}
@media (min-width: 768px) {
.provider-split-container {
grid-template-columns: 30% 70%;
}
}
/* 30% List Pane */
.provider-list-pane {
border-right: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
flex-direction: column;
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
background: #f3f4f6;
}
.pane-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: #111;
}
.btn-add-inst {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
background: #10b981;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-add-inst:hover {
background: #059669;
}
.pane-list {
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.no-instances-msg {
padding: 2rem 1rem;
text-align: center;
color: #6b7280;
font-size: 0.8125rem;
}
.instance-list-item {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
transition: background 0.15s, border-left 0.15s;
border-left: 3px solid transparent;
}
.instance-list-item:hover {
background: #f3f4f6;
}
.instance-list-item.active {
background: #eff6ff;
border-left: 3px solid #3b82f6;
}
.item-name {
font-weight: 500;
font-size: 0.875rem;
color: #111;
}
.item-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.active-pill {
background: #dcfce7;
color: #15803d;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
}
/* 70% Form Pane */
.provider-form-pane {
background: #fff;
display: flex;
flex-direction: column;
}
.provider-config-form {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.form-scroll-content {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-scroll-content h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #111;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group select {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-group input:focus,
.form-group select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.checkbox-group {
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.checkbox-group label {
cursor: pointer;
}
.checkbox-group input {
width: 1rem;
height: 1rem;
cursor: pointer;
}
/* Action bar */
.form-actions-bar {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-delete-pane {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: #ef4444;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-delete-pane:hover {
background: #dc2626;
}
.btn-save-pane {
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-save-pane:hover {
background: #1d4ed8;
}
/* Task Provider Routing Styles */
.mappings-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1rem;
}
@media (min-width: 768px) {
.mappings-grid {
grid-template-columns: 1fr 1fr;
}
}
.mapping-card {
border: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.75rem;
}
.mapping-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8125rem;
}
.mapping-info strong {
font-size: 0.875rem;
color: #111;
}
.mapping-card select {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
width: 100%;
}
.text-gray {
color: #6b7280;
}
`}</style>
</div>
);
}

View File

@@ -1,18 +1,10 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
color: #111;
background: #fafafa;
}
body {
min-height: 100dvh;
@layer base {
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
}

BIN
apps/gui/src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

View File

@@ -10,7 +10,7 @@ export const metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<body className="min-h-dvh bg-[#fafafa] text-[#111]">
<NavBar />
{children}
</body>

View File

@@ -2,62 +2,31 @@ import Link from "next/link";
export default function Home() {
return (
<main className="home">
<h1>Omnia GUI</h1>
<p className="subtitle">
<main className="mx-auto max-w-[800px] px-4 py-12">
<h1 className="mb-2 text-3xl">Omnia GUI</h1>
<p className="mb-8 text-gray-500">
Configuration and gameplay interface for the Omnia simulation engine.
</p>
<div className="home-links">
<Link href="/play" className="home-card">
<h2>Play</h2>
<p>Start a simulation and interact with NPCs</p>
<div className="flex gap-4">
<Link
href="/play"
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
>
<h2 className="mb-1 text-xl">Play</h2>
<p className="text-sm text-gray-500">
Start a simulation and interact with NPCs
</p>
</Link>
<Link href="/config" className="home-card">
<h2>Config</h2>
<p>Check environment, API keys, and available scenarios</p>
<Link
href="/config"
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
>
<h2 className="mb-1 text-xl">Config</h2>
<p className="text-sm text-gray-500">
Check environment, API keys, and available scenarios
</p>
</Link>
</div>
<style>{`
.home {
max-width: 800px;
margin: 0 auto;
padding: 3rem 1rem;
}
.home h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.subtitle {
color: #555;
margin-bottom: 2rem;
}
.home-links {
display: flex;
gap: 1rem;
}
.home-card {
flex: 1;
display: block;
padding: 1.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.home-card:hover {
border-color: #2563eb;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
}
.home-card h2 {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.home-card p {
font-size: 0.875rem;
color: #555;
}
`}</style>
</main>
);
}

View File

@@ -4,7 +4,7 @@ import path from "path";
import fs from "fs";
import { simulationManager } from "@/lib/simulation";
import type { SimSnapshot } from "@/lib/simulation";
import { ProviderManager, LLMProviderInstance } from "@omnia/llm";
import { ProviderManager, LLMProviderInstance, AVAILABLE_PROVIDERS, LLMProviderMeta } from "@omnia/llm";
function resolveScenarioPath(relative: string): string {
const cwd = process.cwd();
@@ -274,3 +274,7 @@ export async function setProviderMapping(
): Promise<void> {
ProviderManager.setMapping(task, providerInstanceId);
}
export async function getAvailableProviders(): Promise<LLMProviderMeta[]> {
return AVAILABLE_PROVIDERS;
}

View File

@@ -25,7 +25,7 @@ import {
IActorProseGenerator,
buildBufferEntryForIntent,
} from "@omnia/actor";
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager } from "@omnia/llm";
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type {
@@ -231,9 +231,12 @@ class SimulationManager {
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
if (providerName === "google-genai") {
return new GeminiProvider(key);
return new GeminiProvider(key, modelName);
} else if (providerName === "openrouter") {
return new OpenRouterProvider(key, modelName);
} else {
return new MockLLMProvider([]);
}
@@ -668,7 +671,9 @@ class SimulationManager {
}
if (inst.providerName === "google-genai") {
return new GeminiProvider(inst.apiKey);
return new GeminiProvider(inst.apiKey, inst.modelName);
} else if (inst.providerName === "openrouter") {
return new OpenRouterProvider(inst.apiKey, inst.modelName);
} else {
return new MockLLMProvider([]);
}

View File

@@ -0,0 +1,11 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@@ -47,6 +47,7 @@
},
"dependencies": {
"@langchain/google-genai": "^2.2.0",
"@langchain/openrouter": "^0.4.3",
"@types/node": "^20.19.43",
"dotenv": "^17.4.2"
}

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
const LLMConfigSchema = z.object({
GOOGLE_API_KEY: z.string().optional(),
OPENROUTER_API_KEY: z.string().optional(),
});
export const llmConfig = LLMConfigSchema.parse(process.env);

View File

@@ -2,4 +2,5 @@ export * from "./llm.js";
export * from "./config.js";
export * from "./providers/google-genai.js";
export * from "./providers/mock.js";
export * from "./providers/openrouter.js";
export * from "./provider-manager.js";

View File

@@ -45,3 +45,31 @@ export interface LLMProviderInstance {
isActive: boolean;
modelName?: string;
}
export interface LLMProviderMeta {
id: string;
displayName: string;
description: string;
defaultModel: string;
}
export const AVAILABLE_PROVIDERS: LLMProviderMeta[] = [
{
id: "google-genai",
displayName: "Google Gemini",
description: "Official Gemini integration using Google Gen AI SDK",
defaultModel: "gemini-2.5-flash",
},
{
id: "openrouter",
displayName: "OpenRouter",
description: "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
defaultModel: "google/gemini-2.5-flash",
},
{
id: "mock",
displayName: "Mock LLM Provider",
description: "Stateless mock provider for testing and offline development",
defaultModel: "mock",
},
];

View File

@@ -5,6 +5,11 @@ import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class GeminiProvider implements ILLMProvider {
static readonly providerId = "google-genai";
static readonly displayName = "Google Gemini";
static readonly description = "Official Gemini integration using Google Gen AI SDK";
static readonly defaultModel = "gemini-2.5-flash";
providerName = "Gemini";
private model: ChatGoogleGenerativeAI;
lastCalls: LLMCallRecord[] = [];

View File

@@ -2,6 +2,11 @@ import { z } from "zod";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
export class MockLLMProvider implements ILLMProvider {
static readonly providerId = "mock";
static readonly displayName = "Mock LLM Provider";
static readonly description = "Stateless mock provider for testing and offline development";
static readonly defaultModel = "mock";
providerName = "mock";
private callCount = 0;
lastCalls: LLMCallRecord[] = [];

View File

@@ -0,0 +1,80 @@
import { z } from "zod";
import { ChatOpenRouter } from "@langchain/openrouter";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class OpenRouterProvider implements ILLMProvider {
static readonly providerId = "openrouter";
static readonly displayName = "OpenRouter";
static readonly description = "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
static readonly defaultModel = "google/gemini-2.5-flash";
providerName = "OpenRouter";
private model: ChatOpenRouter;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive();
if (active) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
}
}
if (!key) {
key = llmConfig.OPENROUTER_API_KEY;
}
if (!key) {
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
}
this.model = new ChatOpenRouter({
apiKey: key,
model: model || "google/gemini-2.5-flash",
});
}
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
])) as unknown as {
parsed?: z.infer<T>;
raw?: {
usage_metadata?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
};
};
const parsed = result?.parsed;
const raw = result?.raw;
const usage = raw?.usage_metadata ? {
inputTokens: raw.usage_metadata.input_tokens || 0,
outputTokens: raw.usage_metadata.output_tokens || 0,
totalTokens: raw.usage_metadata.total_tokens || 0,
} : undefined;
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
}
}

View File

@@ -0,0 +1,107 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
import { OpenRouterProvider } from "../src/providers/openrouter.js";
import { llmConfig } from "../src/config.js";
// Mock the ChatOpenRouter class
vi.mock("@langchain/openrouter", () => {
return {
ChatOpenRouter: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
// Return a mock output that matches the includeRaw: true structure
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
};
});
describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new OpenRouterProvider("dummy-key");
expect(provider.providerName).toBe("OpenRouter");
});
test("initializes successfully with apiKey from config", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
try {
const provider = new OpenRouterProvider();
expect(provider.providerName).toBe("OpenRouter");
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
}
});
test("throws error if no API key is provided or in config", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = undefined;
try {
expect(() => new OpenRouterProvider()).toThrow(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider"
);
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new OpenRouterProvider("dummy-key");
const TestSchema = z.object({
name: z.string(),
success: z.boolean(),
});
const response = await provider.generateStructuredResponse({
systemPrompt: "system prompt",
userContext: "user context",
schema: TestSchema,
});
expect(response.success).toBe(true);
expect(response.data).toEqual({
name: "mocked response",
success: true,
});
expect(response.usage).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
});
expect(provider.lastCalls.length).toBe(1);
expect(provider.lastCalls[0]).toEqual({
systemPrompt: "system prompt",
userContext: "user context",
usage: {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
},
});
});
});

588
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ export default defineConfig({
mermaid(),
starlight({
title: "Omnia Docs",
favicon: "/favicon.png",
logo: {
src: "./src/assets/img/logo.png",
replacesTitle: true,

BIN
web/docs/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -21,9 +21,10 @@ export interface ILLMProvider {
}
```
The codebase provides two primary implementations:
The codebase provides three primary implementations:
1. **`GeminiProvider`:** The production provider utilizing Google's Gemini Models via the `@langchain/google-genai` SDK.
2. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests.
2. **`OpenRouterProvider`:** The production provider utilizing OpenRouter via the `@langchain/openrouter` SDK, allowing routing through various third-party and local models.
3. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests.
---
@@ -43,10 +44,10 @@ export interface LLMProviderInstance {
```
Users can register multiple provider instances in the **Configuration Page** under the GUI. Each instance is given:
* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"Experimental Gemini Pro"`).
* A provider type (e.g., `google-genai`, `mock`).
* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"OpenRouter Claude Key"`).
* A provider type (e.g., `google-genai`, `openrouter`, `mock`).
* An API key credential.
* A custom target model name (e.g., `gemini-2.5-flash` or `gemini-2.5-pro`).
* A custom target model name (e.g., `gemini-2.5-flash`, `anthropic/claude-3-5-sonnet`, or local model paths).
* An **Active** status flag (one key is marked as globally active).
Configurations are stored globally in `data/settings.db` (separated from specific simulation run databases like `data/sim-*.db` to keep key storage and audit logs isolated).
@@ -59,10 +60,10 @@ During a simulation run, the engine executes four distinct LLM operations. To op
| Task Name | Key ID | Description | Default Model |
| :--- | :--- | :--- | :--- |
| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` |
| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` |
| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` |
| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` |
| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
If no specific provider instance is mapped to a task, the task automatically routes to the globally marked **Active** provider instance.
@@ -72,7 +73,7 @@ If no specific provider instance is mapped to a task, the task automatically rou
To maintain backwards-compatibility and support headless runs, live evaluation suites, and automated unit tests without requiring database pre-configuration, the config manager supports **self-bootstrapping**:
1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY`.
1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` or `OPENROUTER_API_KEY`.
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Default (Env)`) in `settings.db`.
3. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` to keep execution fluent and error-free.