36 Commits

Author SHA1 Message Date
814f216ea7 feat(gui): Minor UI Improvements to InteractView 2026-07-20 16:16:27 +05:30
7f74617077 chore(ci): Bump pnpm to 11.15.1 2026-07-20 11:47:36 +05:30
264a5ea0fe feat(gui): Duplicate existing model instances (#32)
(fixes Allow Instance Duplication
Fixes #32)
2026-07-20 11:41:28 +05:30
a8b2d425a1 refactor: Unified prompt composition, structured log analysis, parallel validator tracing, and intent hydration fixes 2026-07-19 22:22:58 +05:30
rhit-lid2
8f6fe0dc28 chore: fix linting issues 2026-07-19 22:18:42 +05:30
01ec062194 fix(gui): Add hydration fallback in gui 2026-07-19 21:55:12 +05:30
8ff1650657 feat(gui): Model Statistics now show Validators in the pipeline 2026-07-19 20:32:27 +05:30
7baf583b13 refactor(architect): Use IPromptBuilder Interface for LLMValidator 2026-07-19 20:31:58 +05:30
ee25bf4a4c refactor(llm, memory): Use generic types prompt builder and prompt component for actor,intent and handoff 2026-07-19 18:20:28 +05:30
Regina Fischer
82fb3c34f7 Setup Docker builds and optimize Dockerfile layers (#33)
* feat(ci): Setup docker builds for GUI

* refactor(ci):  Split dockerfile into layers
2026-07-19 18:04:27 +05:30
rhit-lid2
1e34becec7 refactor(gui): Unify prompt analysis components for actor, intent decoder and Handoff 2026-07-19 16:18:15 +05:30
rhit-lid2
f8977a14c6 refactor(gui): Use structured logging over string parsing 2026-07-19 16:08:55 +05:30
c2926261a1 refactor(content): Updated talking room to add more coherent memory 2026-07-19 16:03:20 +05:30
5c3a79e8b6 fix(voice): Quote splitting based on single and double quotes 2026-07-19 13:35:19 +05:30
a4b620502a FEAT!(voice): Implement intent hydration, dehydration system fixes: #29 2026-07-19 13:13:07 +05:30
84bff92631 refactor(intent): Improve intent decoder userContext structure 2026-07-19 11:01:06 +05:30
0512be647c feat(intent): Add additional "thought" intent type 2026-07-19 08:09:06 +05:30
1ed1edf4cf refactor!(memory): Streamline memory terminology and architecture 2026-07-19 07:03:03 +05:30
b4bf70dbae refactor(memory,gui): Streamline memory archtiecture and terminology
- Short term memory/ recent events are collectively now Cognitive Buffer (Tier 1)

- Long term memory/ memory ledgers are just Memory Ledger (Tier 2)

- Dossiers stay Dossiers (Tier 3)
2026-07-18 15:50:12 +05:30
c6b32bb546 Merge branch 'master' into fix/handoff 2026-07-18 14:18:35 +05:30
896ff6f210 feat(scenario): Custom names for simulations 2026-07-18 14:14:57 +05:30
821f6a03cd refactor(gui): Merge master into fix/handoff 2026-07-18 13:51:16 +05:30
9223ae3750 refactor(gui): Pull out Interact and Manage simulation tabs into their own components 2026-07-18 13:46:16 +05:30
32ee8cda3a feat(gui): Added handoff llm call log details to gui 2026-07-18 11:48:54 +05:30
eabe49552d chore: Clean up repo 2026-07-17 09:22:18 +05:30
444c722708 feat(gui): Improve provider icons 2026-07-17 08:28:38 +05:30
64e049c976 feat(gui): Added icons for model providers 2026-07-17 07:25:48 +05:30
74cce6edd1 Merge pull request #24 from sortedcord/feat/more-providers
feat(llm, gui): More Inference Providers
2026-07-16 22:26:30 +05:30
Tom Pike
61ea9fe237 refactor!(llm): Remove bootstrapper in favor of setup-provider 2026-07-16 22:21:59 +05:30
250bb87a8d refactor!(llm): implement new model registration system 2026-07-16 22:05:45 +05:30
8ad94d3fc2 feat(llm): Dynamically fetch available models from providers 2026-07-16 19:16:55 +05:30
2b56c01e4c feat(llm): Added Groq and Deepseek providers 2026-07-16 18:25:07 +05:30
13155cba23 feat(llm): Added OpenAI provider 2026-07-16 13:29:21 +05:30
622fdfe2f1 feat(llm): Added Anthropic Claude provider 2026-07-16 13:21:00 +05:30
d6ab076b09 docs(llm): Added reamde for @omnia/llm 2026-07-16 13:11:23 +05:30
9356e1f7d0 feat(llm): Added ollama provider 2026-07-16 13:10:59 +05:30
119 changed files with 7636 additions and 2280 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
node_modules/
.git/
.gitignore
.gitattributes
*.md
.editorconfig
.prettierrc
.prettierignore
eslint.config.mjs
.env
.env*
tests/
content/
docs/
web/landing/
web/docs/
vitest.config.ts
*.tsbuildinfo
.next/
dist/
.astro/

65
.github/workflows/build-image.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Build Docker Image
on:
push:
branches: [master]
tags: ["v*"]
paths-ignore:
- "web/docs/**"
- "web/landing/**"
- "content/**"
- "README.md"
- "CONTRIBUTING.md"
- ".github/workflows/deploy-docs.yml"
pull_request:
branches: [master]
paths-ignore:
- "web/docs/**"
- "web/landing/**"
- "content/**"
- "*.md"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/omnia-gui
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: apps/gui/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false

View File

@@ -91,7 +91,7 @@ Access the application locally at `http://localhost:3000`.
### The Actor Agent
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its **Cognitive Buffer**, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
Prose is decoded into typed intents:
@@ -121,8 +121,8 @@ Space is a graph: `world → region → location → point of interest`, connect
### Memory Tiers
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
- **Vector Archive (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Cognitive Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
- **Memory Ledger (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Dossier (planned):** Each observer's subjective beliefs about another character.
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
@@ -156,14 +156,14 @@ The finish line for the first milestone is small on purpose. `v0` is almost on t
- [x] Typed intent pipeline: `dialogue` / `action` / `monologue`, decoded from free prose.
- [x] World Architect: LLM validation plus time-delta generation, end-to-end for single actions.
- [x] Actor Agent with epistemically-bounded prompts (self, memory, co-located entities, subjective time).
- [x] Verbatim memory buffer with per-observer subjective serialization and alias resolution.
- [x] Verbatim Cognitive Buffer with per-observer subjective serialization and alias resolution.
- [x] Spatial location graph (data model; perception is co-location only).
- [x] Scenario loader (JSON → SQLite) and a playable CLI loop with human or LLM actors.
**[The `v0` Milestone:](https://github.com/sortedcord/omnia-consolidated/milestone/1)**
- [x] Two hand-authored NPCs live in one location, playable via CLI.
- [x] Each has buffer and vector-archive memory and recalls something said a few turns earlier.
- [x] Each has Cognitive Buffer and Memory Ledger memory and recalls something said a few turns earlier.
- [x] One NPC knows a fact the other does not and, provably by testing, will not leak it.
- [x] The Architect processes at least one non-trivial action per exchange with a visible state change.
- [x] The whole thing persists to a SQLite file and reloads identically.
@@ -183,7 +183,7 @@ omnia/
intent/ intent types (dialogue/action/monologue) and the prose decoder
architect/ World Architect: LLM validation plus time-delta generation
actor/ actor agent: epistemically-bounded prompts, pluggable prose generators
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
memory/ Cognitive Buffer; Memory Ledger (vector archive), dossier, and affect vectors
spatial/ location and POI graph, portal-based perception
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
scenario/ scenario JSON schema and loader (JSON → SQLite)

59
apps/gui/Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm" \
PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
FROM base AS build-deps
RUN apt-get update -qq && \
apt-get install -qq -y --no-install-recommends \
python3 make g++ && \
rm -rf /var/lib/apt/lists/*
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json tsconfig.json tsconfig.base.json ./
COPY apps/gui/package.json apps/gui/package.json
COPY apps/gui/tsconfig.json apps/gui/tsconfig.json
COPY apps/gui/next.config.ts apps/gui/next.config.ts
COPY apps/gui/postcss.config.mjs apps/gui/postcss.config.mjs
COPY apps/gui/components.json apps/gui/components.json
COPY packages/actor/package.json packages/actor/package.json
COPY packages/architect/package.json packages/architect/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/intent/package.json packages/intent/package.json
COPY packages/llm/package.json packages/llm/package.json
COPY packages/memory/package.json packages/memory/package.json
COPY packages/scenario/package.json packages/scenario/package.json
COPY packages/spatial/package.json packages/spatial/package.json
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM build-deps AS pkg-builder
COPY packages/ ./packages/
RUN pnpm build
FROM pkg-builder AS gui-builder
COPY apps/gui/src ./apps/gui/src
COPY apps/gui/public ./apps/gui/public
RUN pnpm --filter @omnia/gui build
FROM base AS runner
ENV NODE_ENV=production
RUN apt-get update -qq && \
apt-get install -qq -y --no-install-recommends \
python3 make g++ && \
rm -rf /var/lib/apt/lists/*
COPY --from=gui-builder /app/package.json /app/pnpm-workspace.yaml ./
COPY --from=gui-builder /app/node_modules ./node_modules
COPY --from=gui-builder /app/packages ./packages
COPY --from=gui-builder /app/apps/gui/package.json \
/app/apps/gui/next.config.ts \
/app/apps/gui/postcss.config.mjs \
./apps/gui/
COPY --from=gui-builder /app/apps/gui/.next ./apps/gui/.next
COPY --from=gui-builder /app/apps/gui/public ./apps/gui/public
WORKDIR /app/apps/gui
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@@ -19,6 +19,7 @@
"@omnia/memory": "workspace:*",
"@omnia/scenario": "workspace:*",
"@omnia/spatial": "workspace:*",
"@omnia/voice": "workspace:*",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -7,8 +7,10 @@ import type { SimSnapshot } from "@/lib/simulation";
import {
ProviderManager,
ModelProviderInstance,
AVAILABLE_PROVIDERS,
getAvailableProviders as listAvailableProviders,
ModelProviderMeta,
ModelLister,
ModelInfo,
} from "@omnia/llm";
function resolveScenarioPath(relative: string): string {
@@ -36,6 +38,7 @@ export async function startSimulation(input: {
scenario?: string;
playEntity?: string;
providerInstanceId?: string;
customName?: string;
}): Promise<ActionResult> {
try {
const scenarioFile =
@@ -50,6 +53,7 @@ export async function startSimulation(input: {
resolved,
input.playEntity || undefined,
input.providerInstanceId,
input.customName,
);
if (snapshot.status === "error") {
@@ -254,6 +258,7 @@ export async function createProviderInstance(
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): Promise<ModelProviderInstance> {
return ProviderManager.create(
name,
@@ -262,9 +267,16 @@ export async function createProviderInstance(
modelName,
type,
maxContext,
endpointUrl,
);
}
export async function duplicateProviderInstance(
id: string,
): Promise<ModelProviderInstance | null> {
return ProviderManager.duplicate(id);
}
export async function deleteProviderInstance(id: string): Promise<void> {
ProviderManager.delete(id);
}
@@ -281,6 +293,7 @@ export async function updateProviderInstance(
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): Promise<void> {
ProviderManager.update(
id,
@@ -290,6 +303,7 @@ export async function updateProviderInstance(
modelName,
type,
maxContext,
endpointUrl,
);
}
@@ -305,7 +319,7 @@ export async function setProviderMapping(
}
export async function getAvailableProviders(): Promise<ModelProviderMeta[]> {
return AVAILABLE_PROVIDERS;
return listAvailableProviders();
}
export async function regenerateEmbeddings(
@@ -313,3 +327,50 @@ export async function regenerateEmbeddings(
): Promise<void> {
await simulationManager.regenerateAllEmbeddings(newProviderInstanceId);
}
/**
* Fetch available models for a provider given its credentials.
* Used when creating a new instance (before it's saved to the DB).
*/
export async function fetchAvailableModels(
providerName: string,
apiKey: string,
endpointUrl?: string,
): Promise<ModelInfo[]> {
return ModelLister.listModels(providerName, apiKey, endpointUrl);
}
/**
* Fetch available models for an existing saved provider instance.
* The API key is retrieved from the DB server-side — never sent to the client.
*/
export async function fetchAvailableModelsForInstance(
instanceId: string,
): Promise<ModelInfo[]> {
const instances = ProviderManager.list();
const inst = instances.find((i) => i.id === instanceId);
if (!inst) return [];
return ModelLister.listModels(
inst.providerName,
inst.apiKey,
inst.endpointUrl,
);
}
export async function renameSimulation(
simId: string,
newName: string,
): Promise<{ ok: true; snapshot: SimSnapshot } | { ok: false; error: string }> {
try {
const snapshot = await simulationManager.rename(simId, newName);
if (!snapshot) {
return { ok: false, error: "Simulation session not found" };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : "Failed to rename simulation",
};
}
}

View File

@@ -103,7 +103,7 @@ export function ConfigView() {
return (
<div className="flex-1 overflow-y-auto w-full relative">
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
Configuration
</h1>
@@ -177,13 +177,13 @@ export function ConfigView() {
{
key: "handoff",
label: "Memory Handoff Engine",
desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.",
desc: "Promotes entities' Cognitive Buffer entries to the Memory Ledger via LLM summarization and pruning.",
type: "generative",
},
{
key: "embeddings",
label: "Text Embeddings Generator",
desc: "Generates vector embeddings for long-term memory retrieval.",
desc: "Generates vector embeddings for Memory Ledger retrieval.",
type: "embedding",
},
].map((task) => (

View File

@@ -1,14 +1,21 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
createProviderInstance,
updateProviderInstance,
setActiveProviderInstance,
regenerateEmbeddings,
deleteProviderInstance,
duplicateProviderInstance,
fetchAvailableModels,
fetchAvailableModelsForInstance,
} from "@/app/actions";
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
import type {
ModelInfo,
ModelProviderInstance,
ModelProviderMeta,
} from "@omnia/llm";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -22,6 +29,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Card,
CardHeader,
@@ -29,15 +44,20 @@ import {
CardTitle,
CardAction,
} from "@/components/ui/card";
import {
Item,
ItemContent,
ItemGroup,
ItemTitle,
ItemDescription,
} from "@/components/ui/item";
import { Item, ItemContent, ItemGroup, ItemTitle } from "@/components/ui/item";
import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { cn } from "@/lib/utils";
import { RefreshCwIcon } from "lucide-react";
const providerLogoMap: Record<string, string> = {
anthropic: "/claude_logo.webp",
"google-genai": "/gemini_logo.webp",
openrouter: "/openrouter_logo.webp",
ollama: "/ollama_logo.webp",
deepseek: "/deepseek_logo.webp",
openai: "/openai_logo.webp",
groq: "/groq_logo.webp",
};
interface ProviderInstancesConfigProps {
instances: ModelProviderInstance[];
@@ -64,9 +84,65 @@ export function ProviderInstancesConfig({
"generative",
);
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
const [editEndpointUrl, setEditEndpointUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Model listing state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fetch models for an existing saved instance
const fetchModelsForExistingInstance = async (instanceId: string) => {
setModelsLoading(true);
setAvailableModels([]);
try {
const models = await fetchAvailableModelsForInstance(instanceId);
setAvailableModels(models);
} catch {
setAvailableModels([]);
} finally {
setModelsLoading(false);
}
};
// Fetch models for a new instance (using live key/endpoint from form)
const fetchModelsForNewInstance = (
provider: string,
apiKey: string,
endpointUrl: string,
) => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
const isOllama = provider === "ollama";
const hasCredentials = isOllama
? endpointUrl.trim().length > 0
: apiKey.trim().length > 8; // don't spam API with partial keys
if (!hasCredentials) {
setAvailableModels([]);
return;
}
debounceTimer.current = setTimeout(async () => {
setModelsLoading(true);
setAvailableModels([]);
try {
const models = await fetchAvailableModels(
provider,
isOllama ? "none" : apiKey,
isOllama ? endpointUrl : undefined,
);
setAvailableModels(models);
} catch {
setAvailableModels([]);
} finally {
setModelsLoading(false);
}
}, 600);
};
useEffect(() => {
if (selectedInstanceId === null) {
setEditName("");
@@ -76,6 +152,8 @@ export function ProviderInstancesConfig({
setEditIsActive(false);
setEditType("generative");
setEditMaxContext(32768);
setEditEndpointUrl("");
setAvailableModels([]);
} else if (selectedInstanceId === "new") {
setEditName("");
const defaultProvider = "google-genai";
@@ -86,6 +164,8 @@ export function ProviderInstancesConfig({
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(false);
setEditMaxContext(32768);
setEditEndpointUrl("");
setAvailableModels([]);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst) {
@@ -109,10 +189,20 @@ export function ProviderInstancesConfig({
? inst.maxContext
: 32768,
);
setEditEndpointUrl(inst.endpointUrl || "");
setAvailableModels([]);
// Auto-fetch models for existing instances
fetchModelsForExistingInstance(selectedInstanceId);
}
}
}, [selectedInstanceId, instances, availableProviders]);
// Re-fetch models when provider/key/endpoint changes on new instance form
useEffect(() => {
if (selectedInstanceId !== "new") return;
fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl);
}, [editProvider, editKey, editEndpointUrl, selectedInstanceId]);
const handleProviderChange = (providerId: string | null) => {
if (!providerId) return;
setEditProvider(providerId);
@@ -122,6 +212,7 @@ export function ProviderInstancesConfig({
? pMeta?.defaultEmbeddingModel || ""
: pMeta?.defaultModel || "",
);
setAvailableModels([]);
};
const handleTypeChange = (type: "generative" | "embedding") => {
@@ -134,6 +225,14 @@ export function ProviderInstancesConfig({
);
};
const handleRefreshModels = () => {
if (selectedInstanceId && selectedInstanceId !== "new") {
fetchModelsForExistingInstance(selectedInstanceId);
} else {
fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!editName.trim()) {
@@ -149,7 +248,7 @@ export function ProviderInstancesConfig({
let targetInstanceId = selectedInstanceId;
if (selectedInstanceId === "new") {
if (!editKey.trim()) {
if (editProvider !== "ollama" && !editKey.trim()) {
setError("API Key is required for new instances.");
setLoading(false);
return;
@@ -157,10 +256,13 @@ export function ProviderInstancesConfig({
const created = await createProviderInstance(
editName,
editProvider,
editKey,
editProvider === "ollama" ? "none" : editKey,
editModel || undefined,
editType,
editType === "generative" ? editMaxContext : 0,
editProvider === "ollama"
? editEndpointUrl || "http://localhost:11434"
: undefined,
);
if (editIsActive) {
await setActiveProviderInstance(created.id);
@@ -194,10 +296,13 @@ export function ProviderInstancesConfig({
selectedInstanceId,
editName,
editProvider,
editKey || undefined,
editProvider === "ollama" ? "none" : editKey || undefined,
editModel || undefined,
editType,
editType === "generative" ? editMaxContext : 0,
editProvider === "ollama"
? editEndpointUrl || "http://localhost:11434"
: undefined,
);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
@@ -234,8 +339,25 @@ export function ProviderInstancesConfig({
}
};
const handleDuplicate = async () => {
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
try {
setLoading(true);
setError("");
const duplicated = await duplicateProviderInstance(selectedInstanceId);
if (duplicated) {
setSelectedInstanceId(duplicated.id);
await onChanged();
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return (
<section className="mb-8">
<section className="mb-8 flex min-h-[600px] flex-col">
{error && (
<div className="mb-4 rounded border-2 border-red-500 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
@@ -243,7 +365,7 @@ export function ProviderInstancesConfig({
)}
<div
className={cn(
"mt-4 grid min-h-[400px] grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
"mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
loading && "pointer-events-none opacity-60",
"transition-opacity duration-200",
)}
@@ -277,14 +399,22 @@ export function ProviderInstancesConfig({
)}
onClick={() => setSelectedInstanceId(inst.id)}
>
<ItemContent>
<ItemTitle>{inst.name}</ItemTitle>
<ItemDescription>{inst.providerName}</ItemDescription>
<div className="flex flex-row gap-1.5">
{inst.isActive && <Badge>Active</Badge>}
<Badge variant="outline">
{inst.type === "generative" ? "gen" : "embed"}
</Badge>
<ItemContent className="flex-row items-center gap-3">
{providerLogoMap[inst.providerName] && (
<img
src={providerLogoMap[inst.providerName]}
alt={inst.providerName}
className="max-h-[38px] max-w-[38px] shrink-0 rounded-sm object-contain"
/>
)}
<div className="flex flex-col gap-1">
<ItemTitle>{inst.name}</ItemTitle>
<div className="flex flex-row gap-1.5">
{inst.isActive && <Badge>Active</Badge>}
<Badge variant="outline">
{inst.type === "generative" ? "gen" : "embed"}
</Badge>
</div>
</div>
</ItemContent>
</Item>
@@ -329,6 +459,7 @@ export function ProviderInstancesConfig({
<div className="grid grid-cols-[2fr_3fr] gap-4">
<div className="flex flex-col gap-1.5">
<Label>Instance Type</Label>
{/* TODO: Change this to choice card */}
<Select
value={editType}
onValueChange={(v) =>
@@ -336,11 +467,11 @@ export function ProviderInstancesConfig({
}
items={[
{
label: "Generative (Text Completion)",
label: "Generative (Text Generation)",
value: "generative",
},
{
label: "Embedding (Vector generation)",
label: "Embedding (Vector Embeddings)",
value: "embedding",
},
]}
@@ -351,10 +482,10 @@ export function ProviderInstancesConfig({
<SelectContent>
<SelectGroup>
<SelectItem value="generative">
Generative (Chat / Text Completion)
Generative (Text Generation)
</SelectItem>
<SelectItem value="embedding">
Embedding (Vector generation)
Embedding (Vector Embeddings)
</SelectItem>
</SelectGroup>
</SelectContent>
@@ -398,30 +529,109 @@ export function ProviderInstancesConfig({
</span>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="formKey">API Key</Label>
<Input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
/>
</div>
{editProvider !== "ollama" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="formKey">API Key</Label>
<Input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
/>
</div>
)}
{editProvider === "ollama" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="formEndpoint">Endpoint URL</Label>
<Input
id="formEndpoint"
value={editEndpointUrl}
onChange={(e) => setEditEndpointUrl(e.target.value)}
placeholder="e.g. http://localhost:11434"
required
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="formModel">Model Name</Label>
<Input
id="formModel"
<div className="flex items-center justify-between">
<Label htmlFor="formModel">Model</Label>
<button
type="button"
onClick={handleRefreshModels}
disabled={modelsLoading}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
title="Refresh model list"
>
<RefreshCwIcon
className={cn(
"size-3",
modelsLoading && "animate-spin",
)}
/>
{modelsLoading
? "Fetching…"
: availableModels.length > 0
? `${availableModels.length} models`
: "Fetch models"}
</button>
</div>
<Combobox
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
/>
onValueChange={(v) => {
if (v) setEditModel(v);
}}
items={availableModels.map((m) => m.id)}
>
<ComboboxInput
id="formModel"
placeholder={
modelsLoading
? "Fetching models…"
: availableModels.length > 0
? "Search or select a model…"
: "e.g. gemini-2.5-flash"
}
disabled={modelsLoading}
showClear={false}
value={editModel}
onChange={(e) =>
setEditModel((e.target as HTMLInputElement).value)
}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
{modelsLoading
? "Fetching models…"
: "No models found. Type a custom model name above."}
</ComboboxEmpty>
<ComboboxList>
{(modelId: string) => {
const model = availableModels.find(
(m) => m.id === modelId,
);
return (
<ComboboxItem key={modelId} value={modelId}>
<span className="flex-1 truncate">{modelId}</span>
{model?.ownedBy && (
<span className="ml-2 shrink-0 text-xs text-muted-foreground">
{model.ownedBy}
</span>
)}
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{editType === "generative" && (
@@ -454,16 +664,26 @@ export function ProviderInstancesConfig({
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<div className="flex flex-row gap-2">
{selectedInstanceId !== "new" && (
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
Delete
</Button>
<>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
Delete
</Button>
<Button
type="button"
variant="secondary"
onClick={handleDuplicate}
disabled={loading}
>
Duplicate
</Button>
</>
)}
</div>
<Button type="submit" disabled={loading}>

View File

@@ -25,14 +25,8 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Bot, UserRound } from "lucide-react";
export function HomeView() {
const router = useRouter();
@@ -59,6 +53,7 @@ export function HomeView() {
const [loadingEntities, setLoadingEntities] = useState(false);
const [selectedEntityForModal, setSelectedEntityForModal] =
useState<string>("");
const [customName, setCustomName] = useState<string>("");
const loadSavedSessions = useCallback(async () => {
try {
@@ -144,6 +139,7 @@ export function HomeView() {
setScenarioForModal(scenario);
setLoadingEntities(true);
setSelectedEntityForModal(""); // Reset selection to Spectator
setCustomName(scenario.name); // Set custom simulation name default
try {
const res = await getScenarioEntities(scenario.path);
if (res.ok) {
@@ -168,6 +164,7 @@ export function HomeView() {
const result = await startSimulation({
scenario: targetScenario.path,
playEntity: selectedEntityForModal || undefined,
customName: customName.trim() || undefined,
});
if (!result.ok) {
@@ -188,7 +185,7 @@ export function HomeView() {
return (
<div className="flex-1 overflow-y-auto w-full relative">
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
<div className="relative z-10 mx-auto max-w-5xl px-10 py-12">
<div className="animate-fade-in">
{/* Centered Big Logo */}
<div className="flex flex-col items-center justify-center mb-10 pt-4">
@@ -233,7 +230,7 @@ export function HomeView() {
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-[148px]"
className="shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-37"
>
<div className="space-y-3">
<Skeleton className="h-5 w-2/3" />
@@ -260,7 +257,7 @@ export function HomeView() {
? undefined
: () => handleResume(s.id)
}
className={`flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
className={`shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
providerInstances.length === 0
? "opacity-50 cursor-not-allowed filter grayscale"
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
@@ -306,8 +303,8 @@ export function HomeView() {
</h2>
{loadingData ? (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<Link href="/builder" className="no-underline shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
<div>
<strong className="text-body-md text-foreground block mb-1">
Build a scenario
@@ -327,7 +324,7 @@ export function HomeView() {
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-[148px]"
className="shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-37"
>
<div className="space-y-3">
<Skeleton className="h-5 w-3/4" />
@@ -341,8 +338,8 @@ export function HomeView() {
</div>
) : (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<Link href="/builder" className="no-underline shrink-0">
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
<div>
<strong className="text-body-md text-surface block mb-1">
Build a scenario
@@ -378,7 +375,7 @@ export function HomeView() {
open={!!scenarioForModal}
onOpenChange={(open) => !open && setScenarioForModal(null)}
>
<DialogContent className="max-w-[400px]">
<DialogContent className="max-w-100">
<DialogHeader className="border-b border-dotted border-border/20 pb-4 mb-2">
<DialogTitle>Start Scenario</DialogTitle>
<DialogDescription>
@@ -394,32 +391,51 @@ export function HomeView() {
</div>
) : (
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Custom Simulation Name
</label>
<Input
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder="Enter custom name..."
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Simulation Mode / Play as
</label>
<Select
value={selectedEntityForModal}
onValueChange={(val) =>
setSelectedEntityForModal(val || "")
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="-- Run Fully Autonomously --" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="">
-- Run Fully Autonomously --
</SelectItem>
{modalEntities.map((ent) => (
<SelectItem key={ent.id} value={ent.id}>
Play as {ent.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={
selectedEntityForModal === ""
? "default"
: "outline"
}
onClick={() => setSelectedEntityForModal("")}
className="flex-1 min-w-40"
>
<Bot />
Run Fully Autonomously
</Button>
{modalEntities.map((ent) => (
<Button
key={ent.id}
type="button"
variant={
selectedEntityForModal === ent.id
? "default"
: "outline"
}
onClick={() => setSelectedEntityForModal(ent.id)}
className="flex-1 min-w-40"
>
<UserRound />
Play as {ent.name}
</Button>
))}
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,260 @@
"use client";
import { useState } from "react";
import type { SimSnapshot } from "@/lib/simulation-types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { PromptAnalyzer } from "@/components/play/PromptAnalyzer";
interface HandoffModalProps {
entry: SimSnapshot["log"][number];
onClose: () => void;
}
export function HandoffModal({ entry, onClose }: HandoffModalProps) {
const [activeTab, setActiveTab] = useState<"chunks" | "prompt" | "output">(
"chunks",
);
const handoffResult = entry.handoffResult;
const chunks = handoffResult?.chunks || [];
const getImportanceColor = (score: number) => {
if (score >= 8)
return "bg-destructive/10 text-destructive border-destructive/30";
if (score >= 5) return "bg-amber-500/10 text-amber-500 border-amber-500/30";
return "bg-emerald-500/10 text-emerald-500 border-emerald-500/30";
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-200 sm:max-w-200 h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
<DialogHeader className="px-6 pt-5 pb-4 border-b">
<DialogTitle className="text-lg font-head tracking-wide text-primary flex items-center justify-between">
<span>Memory Handoff Details &mdash; {entry.entityName}</span>
{entry.usage && (
<span className="text-xs font-mono font-normal text-muted-foreground">
{entry.usage.modelName || "Handoff Model"}
</span>
)}
</DialogTitle>
</DialogHeader>
{/* Custom Tab Switcher */}
<div className="flex border-b bg-muted/20 px-6 py-2 gap-2">
<button
onClick={() => setActiveTab("chunks")}
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
activeTab === "chunks"
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
: "border-transparent hover:bg-secondary text-muted-foreground"
}`}
>
Promoted Chunks ({chunks.length})
</button>
<button
onClick={() => setActiveTab("prompt")}
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
activeTab === "prompt"
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
: "border-transparent hover:bg-secondary text-muted-foreground"
}`}
>
Raw LLM Prompt
</button>
<button
onClick={() => setActiveTab("output")}
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
activeTab === "output"
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
: "border-transparent hover:bg-secondary text-muted-foreground"
}`}
>
Raw JSON Output
</button>
</div>
<div className="overflow-y-auto flex-1 p-6 space-y-4">
{activeTab === "chunks" && (
<div className="space-y-4">
{entry.usage && (
<div className="grid grid-cols-3 gap-4 border border-dotted border-border/20 p-3 bg-secondary/10 rounded text-xs font-mono">
<div>
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
Input Tokens
</span>
<strong className="text-foreground">
{entry.usage.inputTokens}
</strong>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
Output Tokens
</span>
<strong className="text-foreground">
{entry.usage.outputTokens}
</strong>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
Total Tokens
</span>
<strong className="text-foreground">
{entry.usage.totalTokens}
</strong>
</div>
</div>
)}
{chunks.length === 0 ? (
<div className="border border-dotted border-border/30 p-8 text-center bg-card text-muted-foreground rounded">
<p className="text-sm">
No memories were promoted to the Memory Ledger during this
turn.
</p>
<p className="text-xs mt-1">
All Cognitive Buffer entries were summarized or forgotten.
</p>
</div>
) : (
<div className="space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Memory Ledger Additions
</h3>
{chunks.map(
(
chunk: {
content: string;
importance: number;
quotes?: string[];
retainInBuffer?: boolean;
involvedEntityIds?: string[];
},
index: number,
) => (
<div
key={index}
className="border border-border/30 bg-card p-4 shadow-sm relative flex flex-col gap-3"
>
<div className="flex justify-between items-start gap-4">
<div className="flex-1 text-sm text-foreground/90 leading-relaxed font-sans">
{chunk.content}
</div>
<Badge
variant="outline"
className={`font-mono text-xs ${getImportanceColor(chunk.importance)}`}
>
Importance: {chunk.importance}
</Badge>
</div>
{chunk.quotes && chunk.quotes.length > 0 && (
<div className="bg-secondary/10 border-l-2 border-primary/50 p-2.5 my-1 text-xs italic text-muted-foreground space-y-1">
{chunk.quotes.map((quote: string, qIdx: number) => (
<div key={qIdx}>&ldquo;{quote}&rdquo;</div>
))}
</div>
)}
<div className="flex flex-wrap gap-2 text-xs pt-2 border-t border-dotted border-border/10">
{chunk.retainInBuffer ? (
<Badge
variant="outline"
className="bg-primary/5 text-primary border-primary/20 text-[10px] font-mono"
>
Pinned in Buffer
</Badge>
) : (
<Badge
variant="outline"
className="bg-muted text-muted-foreground border-border/20 text-[10px] font-mono"
>
Pruned from Buffer
</Badge>
)}
{chunk.involvedEntityIds &&
chunk.involvedEntityIds.length > 0 && (
<div className="flex items-center gap-1.5 ml-auto text-[10px] font-mono text-muted-foreground">
<span>Entities:</span>
{chunk.involvedEntityIds.map(
(entId: string) => (
<Badge
key={entId}
variant="outline"
className="text-[10px] px-1 py-0 border-border/20 font-mono"
>
{entId}
</Badge>
),
)}
</div>
)}
</div>
</div>
),
)}
</div>
)}
</div>
)}
{activeTab === "prompt" && entry.rawPrompt && (
<PromptAnalyzer
components={
entry.rawPrompt.components &&
entry.rawPrompt.components.length > 0
? entry.rawPrompt.components
: [
{
label: "System Prompt",
type: "system",
content: entry.rawPrompt.systemPrompt || "",
},
{
label: "User Context",
type: "world",
content: entry.rawPrompt.userContext || "",
},
]
}
inputTokens={entry.usage?.inputTokens || 0}
maxContext={
entry.usage?.maxContext !== undefined
? entry.usage.maxContext
: 32768
}
modelName={entry.usage?.modelName}
providerInstanceName={entry.usage?.providerInstanceName}
outputLabel="LLM Output (Promoted Memory Chunks)"
outputText={
handoffResult
? JSON.stringify(handoffResult, null, 2)
: undefined
}
outputTokens={entry.usage?.outputTokens}
/>
)}
{activeTab === "output" && (
<div className="space-y-2 h-full flex flex-col">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Raw JSON Output
</h4>
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border flex-1 overflow-y-auto max-h-125">
{handoffResult
? JSON.stringify(handoffResult, null, 2)
: "No JSON Output recorded."}
</pre>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { formatSimDate, formatSimTimeHM, getClockIcon } from "@/lib/utils";
interface InteractDockProps {
snapshot: SimSnapshot;
loading: boolean;
playerInput: string;
setPlayerInput: (value: string) => void;
onSubmitAction: (e: React.FormEvent<HTMLFormElement>) => void;
onPauseRequested: () => void;
onResumeRequested: () => void;
onStopRequested: () => void;
}
export function InteractDock({
snapshot,
loading,
playerInput,
setPlayerInput,
onSubmitAction,
onPauseRequested,
onResumeRequested,
onStopRequested,
}: InteractDockProps) {
const router = useRouter();
return (
<footer className="sticky bottom-0 px-8 py-4 z-20 shrink-0 bg-background/70 backdrop-blur-md border-t border-border/10">
<div className="max-w-200 mx-auto relative">
{snapshot.status === "running" ||
snapshot.status === "waiting_player" ? (
<div className="border border-border/30 bg-card/85 p-4 shadow-sm backdrop-blur-sm relative z-10">
{snapshot.status === "waiting_player" && snapshot.waitingEntity && (
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>Your context as {snapshot.waitingEntity.name}</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-37.5 overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
)}
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center gap-2">
{snapshot.worldTime ? (
<span className="text-base font-mono text-muted-foreground font-medium">
<img
src="/calendar_logo.png"
alt="Date"
className="w-7 h-7 inline-block align-middle mr-1 opacity-70"
/>
{formatSimDate(snapshot.worldTime)}
<img
src={getClockIcon(snapshot.worldTime)}
alt="Time"
className="w-7 h-7 inline-block align-middle ml-2 mr-1 opacity-70"
/>
{formatSimTimeHM(snapshot.worldTime)}
{snapshot.currentLocation && (
<>
<img
src="/map_pointer_icon.png"
alt="Location"
className="w-7 h-7 inline-block align-middle ml-2 mr-1 opacity-70"
/>
{snapshot.currentLocation}
</>
)}
</span>
) : (
<div />
)}
<div className="flex gap-2">
{loading ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={onPauseRequested}
>
Pause
</Button>
) : (
snapshot.status === "running" && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={onResumeRequested}
>
Resume
</Button>
)
)}
<Button
type="button"
variant="destructive"
size="sm"
onClick={onStopRequested}
>
Stop
</Button>
</div>
</div>
{snapshot.status === "waiting_player" &&
snapshot.waitingEntity && (
<form
onSubmit={onSubmitAction}
className="flex flex-row gap-2 items-stretch"
>
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
className="flex-1 min-h-26"
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !playerInput.trim()}
className="w-32 shrink-0 self-stretch"
>
{loading ? "Processing..." : "Submit"}
</Button>
</form>
)}
</div>
</div>
) : snapshot.status === "done" || snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card/85 border border-border/30 p-4 shadow-sm backdrop-blur-sm">
<div className="flex flex-col gap-1">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
{snapshot.worldTime && (
<div className="flex items-center gap-2 text-base font-mono text-muted-foreground">
<img
src="/calendar_logo.png"
alt="Date"
className="w-7 h-7 opacity-70"
/>
<span>{formatSimDate(snapshot.worldTime)}</span>
<img
src={getClockIcon(snapshot.worldTime)}
alt="Time"
className="w-7 h-7 opacity-70"
/>
<span>{formatSimTimeHM(snapshot.worldTime)}</span>
{snapshot.currentLocation && (
<>
<img
src="/map_pointer_icon.png"
alt="Location"
className="w-7 h-7 opacity-70"
/>
<span>{snapshot.currentLocation}</span>
</>
)}
</div>
)}
</div>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import * as React from "react";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { hydrate } from "@omnia/voice";
import { Brain, PersonStanding, Speech } from "lucide-react";
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { InteractDock } from "./InteractDock";
function IntentTag({
intent,
playerAliases,
playerId,
entities,
}: {
intent: SimSnapshot["log"][number]["intents"][number];
playerAliases: Record<string, string>;
playerId: string;
entities: SimSnapshot["entities"];
}) {
const icons: Record<string, React.ReactNode> = {
monologue: <Brain className="size-4" />,
thought: <Brain className="size-4" />,
dialogue: <Speech className="size-4" />,
action: <PersonStanding className="size-4" />,
};
const icon = icons[intent.type] || null;
const viewerAliasesMap = new Map<string, string>();
if (entities) {
for (const ent of entities) {
viewerAliasesMap.set(ent.id, ent.name || ent.id);
}
}
if (playerAliases) {
for (const [targetId, alias] of Object.entries(playerAliases)) {
viewerAliasesMap.set(targetId, alias);
}
}
const viewerEntityMock = {
id: playerId || "",
aliases: viewerAliasesMap,
};
const textToDisplay = hydrate(
intent.content,
viewerEntityMock as unknown as Parameters<typeof hydrate>[1],
);
const modifiersStr =
intent.modifiers && intent.modifiers.length > 0 ? (
<span className="italic opacity-80 text-muted-foreground ml-1">
({intent.modifiers.join(", ")})
</span>
) : null;
const invalidActionReason =
intent.type === "action" && !intent.isValid && intent.reason
? ` (${intent.reason})`
: "";
const invalidActionClassName =
intent.type === "action" && !intent.isValid ? " text-destructive" : "";
return (
<>
<span className="text-sm text-muted-foreground inline-flex items-start gap-1">
<span className="mt-0.5 inline-flex shrink-0 items-center justify-center">
{icon}
</span>
<span className={invalidActionClassName}>
&ldquo;{textToDisplay}&rdquo;{modifiersStr}
{invalidActionReason}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
</span>
<br />
</>
);
}
function formatSimTime(isoString: string) {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const min = String(d.getUTCMinutes()).padStart(2, "0");
const ss = String(d.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
} catch {
return isoString;
}
}
function LogEntryCard({
entry,
onShowPrompt,
isPlayerCard,
playerAliases,
playerId,
entities,
}: {
entry: SimSnapshot["log"][number];
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
isPlayerCard: boolean;
playerAliases: Record<string, string>;
playerId: string;
entities: SimSnapshot["entities"];
}) {
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
return (
<div className={cn("mb-2")}>
<div
className={cn(
"border p-4 shadow-sm",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
)}
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
</div>
<div className={cn("mt-3 ms-3")}>
{entry.intents.map((intent, i) => (
<IntentTag
key={i}
intent={intent}
playerAliases={playerAliases}
playerId={playerId}
entities={entities}
/>
))}
</div>
</div>
);
}
interface InteractViewProps {
snapshot: SimSnapshot;
loading: boolean;
statusText: string;
playerInput: string;
setPlayerInput: (value: string) => void;
onSubmitAction: (e: React.FormEvent<HTMLFormElement>) => void;
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
onShowHandoff: (entry: SimSnapshot["log"][number]) => void;
logEndRef: React.RefObject<HTMLDivElement | null>;
onPauseRequested: () => void;
onResumeRequested: () => void;
onStopRequested: () => void;
}
export function InteractView({
snapshot,
loading,
statusText,
playerInput,
setPlayerInput,
onSubmitAction,
onShowPrompt,
onShowHandoff,
logEndRef,
onPauseRequested,
onResumeRequested,
onStopRequested,
}: InteractViewProps) {
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return (
<>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
<div className="flex flex-col gap-4 max-w-200 mx-auto pb-44 md:pb-52">
{snapshot.log.map((entry, i) => {
if (entry.isHandoff) {
return (
<Alert
key={i}
className="max-w-md border-dashed bg-secondary/10"
>
<div className="flex-1">
<AlertTitle>
Handoff triggered for {entry.entityName}
</AlertTitle>
<AlertDescription>
Memories were transferred from Cognitive Buffer to Memory
Ledger
</AlertDescription>
</div>
<AlertAction>
<Button
size="xs"
variant="default"
onClick={() => onShowHandoff(entry)}
>
View Details
</Button>
</AlertAction>
</Alert>
);
}
const playerAliases = playerEntity?.aliases || {};
const playerId = playerEntity?.id || "";
return (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={onShowPrompt}
isPlayerCard={entry.entityId === playerEntity?.id}
playerAliases={playerAliases}
playerId={playerId}
entities={snapshot.entities}
/>
);
})}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
</main>
<InteractDock
snapshot={snapshot}
loading={loading}
playerInput={playerInput}
setPlayerInput={setPlayerInput}
onSubmitAction={onSubmitAction}
onPauseRequested={onPauseRequested}
onResumeRequested={onResumeRequested}
onStopRequested={onStopRequested}
/>
</>
);
}

View File

@@ -0,0 +1,176 @@
"use client";
import * as React from "react";
import { useState } from "react";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { renameSimulation } from "@/app/actions";
interface ManageViewProps {
snapshot: SimSnapshot;
onRename: (updated: SimSnapshot) => void;
}
export function ManageView({ snapshot, onRename }: ManageViewProps) {
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState(snapshot.scenarioName);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
React.useEffect(() => {
setEditedName(snapshot.scenarioName);
}, [snapshot.scenarioName]);
const handleSaveName = async () => {
if (!editedName.trim()) return;
setSaving(true);
setError("");
try {
const res = await renameSimulation(snapshot.id, editedName.trim());
if (res.ok) {
onRename(res.snapshot);
setIsEditingName(false);
} else {
setError(res.error);
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to rename simulation",
);
} finally {
setSaving(false);
}
};
return (
<main className="flex-1 overflow-y-auto px-8 py-6">
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
{/* Simulation Info */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Simulation Info
</h3>
{error && (
<div className="mb-4 border border-destructive bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
<div className="flex flex-col gap-1 border-b border-border/10 pb-2 md:col-span-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Simulation Name
</span>
{isEditingName ? (
<div className="flex items-center gap-2 mt-1">
<Input
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
className="h-8 max-w-sm font-sans"
disabled={saving}
/>
<Button size="sm" onClick={handleSaveName} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setIsEditingName(false)}
disabled={saving}
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-3">
<span className="text-foreground font-bold text-base font-head">
{snapshot.scenarioName}
</span>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px]"
onClick={() => {
setEditedName(snapshot.scenarioName);
setIsEditingName(true);
}}
>
Rename
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Session ID
</span>
<span className="text-foreground font-bold break-all">
{snapshot.id}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Max Turns
</span>
<span className="text-foreground font-bold font-mono">
{snapshot.maxTurns}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Turn Count
</span>
<span className="text-foreground font-bold font-mono">
{snapshot.turn}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Entities Registered
</span>
<span className="text-foreground font-bold font-mono">
{snapshot.entities.length}
</span>
</div>
</div>
</div>
{/* Entities Involved */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Entities Involved
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{snapshot.entities.map((ent) => (
<div
key={ent.id}
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
>
<div>
<strong className="text-sm text-foreground block font-head tracking-wide">
{ent.name}
</strong>
<span className="text-xs text-muted-foreground font-mono block mt-1">
ID: {ent.id}
</span>
</div>
<div className="flex items-center gap-2">
{ent.isPlayer ? (
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
PLAYER
</span>
) : (
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
NPC
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
}

View File

@@ -9,9 +9,11 @@ import {
} from "@/app/actions";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { PromptModal } from "./PromptModal";
import { HandoffModal } from "./HandoffModal";
import { InteractView } from "./InteractView";
import { ManageView } from "./ManageView";
import { cn } from "@/lib/utils";
import { ChevronLeft } from "lucide-react";
import {
@@ -22,114 +24,6 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
function IntentTag({
intent,
isSelf,
}: {
intent: SimSnapshot["log"][number]["intents"][number];
isSelf?: boolean;
}) {
const labels: Record<string, string> = {
monologue: "thought",
dialogue: "dialogue",
action: "action",
};
const label = labels[intent.type] || intent.type;
let outcome = "";
if (intent.type === "action") {
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
}
const textToDisplay =
isSelf && intent.selfDescription
? intent.selfDescription
: intent.description;
const modifiersStr =
intent.modifiers && intent.modifiers.length > 0 ? (
<span className="italic opacity-80 text-muted-foreground ml-1">
({intent.modifiers.join(", ")})
</span>
) : null;
return (
<span className="text-sm text-muted-foreground">
[{label}] &ldquo;{textToDisplay}&rdquo;{modifiersStr}
{outcome}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
);
}
function formatSimTime(isoString: string) {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const min = String(d.getUTCMinutes()).padStart(2, "0");
const ss = String(d.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
} catch {
return isoString;
}
}
function LogEntryCard({
entry,
onShowPrompt,
isPlayerCard,
}: {
entry: SimSnapshot["log"][number];
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
isPlayerCard: boolean;
}) {
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
return (
<div
className={cn(
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
)}
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
{entry.intents.map((intent, i) => (
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
))}
</div>
</div>
);
}
function MobileSidebarClose() {
const { isMobile, setOpenMobile } = useSidebar();
if (!isMobile) return null;
@@ -163,6 +57,9 @@ export function PlayView() {
const [selectedEntryForModal, setSelectedEntryForModal] = useState<
SimSnapshot["log"][number] | null
>(null);
const [selectedHandoffForModal, setSelectedHandoffForModal] = useState<
SimSnapshot["log"][number] | null
>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const steppingRef = useRef(false);
@@ -422,43 +319,6 @@ export function PlayView() {
</p>
</div>
</div>
{/* Simulation Global Controls */}
<div className="flex gap-2 shrink-0">
{snapshot.status !== "done" && snapshot.status !== "error" && (
<>
{snapshot.status === "running" &&
(loading ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => runSteps(snapshot.id)}
>
Resume
</Button>
))}
<Button
variant="destructive"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
>
Stop
</Button>
</>
)}
</div>
</div>
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
<span className="text-muted-foreground">
@@ -482,171 +342,28 @@ export function PlayView() {
)}
</header>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === "interact" ? (
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
{(() => {
const playerEntity = snapshot.entities.find(
(e) => e.isPlayer,
);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
) : (
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
{/* Simulation Info */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Simulation Info
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Session ID
</span>
<span className="text-foreground font-bold break-all">
{snapshot.id}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Max Turns
</span>
<span className="text-foreground font-bold">
{snapshot.maxTurns}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Turn Count
</span>
<span className="text-foreground font-bold">
{snapshot.turn}
</span>
</div>
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
<span className="text-muted-foreground text-xs uppercase tracking-wider">
Entities Registered
</span>
<span className="text-foreground font-bold">
{snapshot.entities.length}
</span>
</div>
</div>
</div>
{/* Entities Involved */}
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
Entities Involved
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{snapshot.entities.map((ent) => (
<div
key={ent.id}
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
>
<div>
<strong className="text-sm text-foreground block font-head tracking-wide">
{ent.name}
</strong>
<span className="text-xs text-muted-foreground font-mono block mt-1">
ID: {ent.id}
</span>
</div>
<div className="flex items-center gap-2">
{ent.isPlayer ? (
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
PLAYER
</span>
) : (
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
NPC
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</main>
{/* Sticky Chat / Interaction Input Footer */}
{activeTab === "interact" && (
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
<div className="max-w-[800px] mx-auto">
{snapshot.status === "waiting_player" &&
snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>
Your context as {snapshot.waitingEntity.name}
</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form
onSubmit={handleSubmitAction}
className="flex flex-col gap-2"
>
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !playerInput.trim()}
>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
) : snapshot.status === "done" ||
snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
{activeTab === "interact" ? (
<InteractView
snapshot={snapshot}
loading={loading}
statusText={statusText}
playerInput={playerInput}
setPlayerInput={setPlayerInput}
onSubmitAction={handleSubmitAction}
onShowPrompt={setSelectedEntryForModal}
onShowHandoff={setSelectedHandoffForModal}
logEndRef={logEndRef}
onPauseRequested={() => {
pauseRequestedRef.current = true;
}}
onResumeRequested={() => runSteps(snapshot.id)}
onStopRequested={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
/>
) : (
<ManageView snapshot={snapshot} onRename={setSnapshot} />
)}
</div>
@@ -662,6 +379,13 @@ export function PlayView() {
onClose={() => setSelectedEntryForModal(null)}
/>
)}
{selectedHandoffForModal && (
<HandoffModal
entry={selectedHandoffForModal}
onClose={() => setSelectedHandoffForModal(null)}
/>
)}
</div>
</SidebarProvider>
);

View File

@@ -0,0 +1,177 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import type { PromptComponent } from "@/lib/simulation-types";
interface PromptAnalyzerProps {
components: PromptComponent[];
inputTokens: number;
maxContext?: number;
modelName?: string;
providerInstanceName?: string;
outputLabel?: string;
outputText?: string;
outputTokens?: number;
}
export function PromptAnalyzer({
components,
inputTokens,
maxContext = 32768,
modelName,
providerInstanceName,
outputLabel = "LLM Output",
outputText,
outputTokens,
}: PromptAnalyzerProps) {
const totalLen = components.reduce((sum, s) => sum + s.content.length, 0);
if (totalLen === 0) {
return (
<div className="text-sm italic text-muted-foreground">
No prompt context recorded.
</div>
);
}
const sections = components.map((s) => {
const pct = totalLen > 0 ? (s.content.length / totalLen) * 100 : 0;
return {
...s,
pct,
tokens: Math.round((s.content.length / totalLen) * inputTokens),
};
});
const usagePctOfContext =
maxContext > 0 ? (inputTokens / maxContext) * 100 : 0;
const isAbsolute = maxContext > 0 && usagePctOfContext >= 20;
const legentColors = [
"bg-blue-500",
"bg-emerald-500",
"bg-purple-500",
"bg-orange-500",
"bg-pink-500",
"bg-amber-500",
"bg-teal-500",
"bg-cyan-500",
"bg-indigo-500",
"bg-violet-500",
"bg-rose-500",
"bg-sky-500",
"bg-lime-500",
"bg-fuchsia-500",
"bg-red-500",
];
const getColorClass = (index: number) => {
return legentColors[index % legentColors.length];
};
return (
<div className="flex flex-col gap-4">
{/* Provider Details */}
{(providerInstanceName || modelName) && (
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
<strong>LLM Instance:</strong>{" "}
<span>{providerInstanceName || "Default"}</span>
{modelName && <span> ({modelName})</span>}
</div>
)}
{/* Progress Bar & Breakdown */}
<div>
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
<span className="font-semibold">Input Prompt Breakdown</span>
<span>
Total Input Tokens: <strong>{inputTokens}</strong>
{maxContext > 0 ? (
<span>
{" "}
/ {maxContext} ({usagePctOfContext.toFixed(1)}% used)
</span>
) : (
<span> (infinite context)</span>
)}
</span>
</div>
{/* Token Bar */}
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
{sections.map((item, idx) => {
const widthPct = isAbsolute
? item.pct * (inputTokens / maxContext)
: item.pct;
return (
<div
key={idx}
className={`h-full transition-all duration-300 ${getColorClass(idx)}`}
style={{ width: `${widthPct}%` }}
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
/>
);
})}
{isAbsolute && (
<div
className="bg-white h-full"
style={{ width: `${100 - usagePctOfContext}%` }}
title={`Available: ${maxContext - inputTokens} tokens (${(100 - usagePctOfContext).toFixed(1)}% remaining)`}
/>
)}
</div>
{/* Accordion Components */}
<Accordion type="multiple" className="w-full">
{sections.map((item, idx) => {
return (
<AccordionItem key={idx} value={String(idx)}>
<AccordionTrigger className="text-sm py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<span
className={`inline-block w-2.5 h-2.5 rounded-sm ${getColorClass(idx)}`}
/>
<span>{item.label}:</span>
<span className="text-muted-foreground font-normal">
<strong>{item.tokens}</strong> tokens (
{item.pct.toFixed(0)}%)
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<pre className="m-0 p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-75 overflow-y-auto">
{item.content}
</pre>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
{/* Output Section */}
{outputText && (
<div>
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2 font-mono">
<span className="font-semibold">{outputLabel}</span>
{outputTokens !== undefined && (
<span>
Total Output Tokens: <strong>{outputTokens}</strong>
</span>
)}
</div>
<div className="rounded border-2">
<pre className="m-0 p-3 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground max-h-62.5 overflow-y-auto">
{outputText}
</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import type { SimSnapshot } from "@/lib/simulation-types";
import type { SimSnapshot, PromptBreakdown } from "@/lib/simulation-types";
import {
Dialog,
DialogContent,
@@ -9,13 +9,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { PromptSwitcher } from "@/components/play/PromptSwitcher";
import { PromptAnalyzer } from "@/components/play/PromptAnalyzer";
interface PromptModalProps {
entry: SimSnapshot["log"][number];
@@ -23,172 +18,7 @@ interface PromptModalProps {
}
export function PromptModal({ entry, onClose }: PromptModalProps) {
const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor");
const parseActorPrompt = (
systemPrompt: string,
userContext: string,
inputTokens: number,
) => {
const recentHeader = "=== RECENT EVENTS ===";
const ledgerHeader = "=== YOUR MEMORIES ===";
const recentIdx = userContext.indexOf(recentHeader);
let worldStr = userContext;
let recentStr = "";
let ledgerStr = "";
if (recentIdx !== -1) {
worldStr = userContext.substring(0, recentIdx).trim();
const rest = userContext.substring(recentIdx).trim();
const ledgerIdx = rest.indexOf(ledgerHeader);
if (ledgerIdx !== -1) {
recentStr = rest.substring(0, ledgerIdx).trim();
ledgerStr = rest.substring(ledgerIdx).trim();
} else {
recentStr = rest;
}
}
const sections: { label: string; type: string; content: string }[] = [
{ label: "System Prompt", type: "system", content: systemPrompt },
{ label: "World Info", type: "world", content: worldStr },
{
label: "Recent Events",
type: "events",
content: recentStr || "(No recent events.)",
},
{
label: "Long-Term Memories",
type: "memories",
content: ledgerStr || "(No long-term memories.)",
},
];
const totalLen = sections.reduce((sum, s) => sum + s.content.length, 0);
if (totalLen === 0) return null;
return sections.map((s) => {
const pct = (s.content.length / totalLen) * 100;
return {
...s,
pct,
relativePct: pct,
tokens: Math.round((s.content.length / totalLen) * inputTokens),
};
});
};
const parseDecoderPrompt = (
systemPrompt: string,
userContext: string,
inputTokens: number,
) => {
const proseHeader = "=== NARRATIVE PROSE ===";
const idx = userContext.indexOf(proseHeader);
let worldStr = userContext;
let proseStr = "";
if (idx !== -1) {
worldStr = userContext.substring(0, idx).trim();
proseStr = userContext.substring(idx).trim();
}
const sysLen = systemPrompt.length;
const worldLen = worldStr.length;
const proseLen = proseStr.length;
const totalLen = sysLen + worldLen + proseLen;
if (totalLen === 0) return null;
const sysPct = (sysLen / totalLen) * 100;
const worldPct = (worldLen / totalLen) * 100;
const prosePct = (proseLen / totalLen) * 100;
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
const proseTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
return [
{
label: "System Prompt",
pct: sysPct,
relativePct: sysPct,
tokens: sysTokens,
type: "system",
content: systemPrompt,
},
{
label: "Decoder Context",
pct: worldPct,
relativePct: worldPct,
tokens: worldTokens,
type: "world",
content: worldStr,
},
{
label: "Narrative Prose",
pct: prosePct,
relativePct: prosePct,
tokens: proseTokens,
type: "memories",
content: proseStr,
},
];
};
const actorBreakdown =
entry.rawPrompt && entry.usage
? parseActorPrompt(
entry.rawPrompt.systemPrompt,
entry.rawPrompt.userContext,
entry.usage.inputTokens,
)
: null;
const decoderBreakdown =
entry.decoderPrompt && entry.decoderUsage
? parseDecoderPrompt(
entry.decoderPrompt.systemPrompt,
entry.decoderPrompt.userContext,
entry.decoderUsage.inputTokens,
)
: null;
const actorMaxContext =
entry.usage?.maxContext !== undefined ? entry.usage.maxContext : 32768;
const actorUsedTokens = entry.usage?.inputTokens || 0;
const actorUsagePctOfContext =
actorMaxContext > 0 ? (actorUsedTokens / actorMaxContext) * 100 : 0;
const isActorAbsolute = actorMaxContext > 0 && actorUsagePctOfContext >= 20;
const scaledActorBreakdown = actorBreakdown
? actorBreakdown.map((item) => ({
...item,
pct: isActorAbsolute
? item.relativePct * (actorUsedTokens / actorMaxContext)
: item.relativePct,
}))
: null;
const decoderMaxContext =
entry.decoderUsage?.maxContext !== undefined
? entry.decoderUsage.maxContext
: 32768;
const decoderUsedTokens = entry.decoderUsage?.inputTokens || 0;
const decoderUsagePctOfContext =
decoderMaxContext > 0 ? (decoderUsedTokens / decoderMaxContext) * 100 : 0;
const isDecoderAbsolute =
decoderMaxContext > 0 && decoderUsagePctOfContext >= 20;
const scaledDecoderBreakdown = decoderBreakdown
? decoderBreakdown.map((item) => ({
...item,
pct: isDecoderAbsolute
? item.relativePct * (decoderUsedTokens / decoderMaxContext)
: item.relativePct,
}))
: null;
const [activeTab, setActiveTab] = useState<string>("actor");
useEffect(() => {
if (!entry.rawPrompt && entry.decoderPrompt) {
@@ -196,9 +26,47 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
}
}, [entry]);
// Helper to resolve components with a fallback if none exist (for backwards-compatibility)
const getComponents = (
promptBreakdown: PromptBreakdown | null | undefined,
defaultType: "world" | "input",
) => {
if (!promptBreakdown) return [];
if (promptBreakdown.components && promptBreakdown.components.length > 0) {
return promptBreakdown.components;
}
// Fallback: convert flat strings into components list
return [
{
label: "System Prompt",
type: "system" as const,
content: promptBreakdown.systemPrompt || "",
},
{
label: "User Context",
type: defaultType,
content: promptBreakdown.userContext || "",
},
];
};
const actorComponents = getComponents(entry.rawPrompt, "world");
const decoderComponents = getComponents(entry.decoderPrompt, "input");
const isValidatorTab = activeTab.startsWith("validator-");
const validatorIndex = isValidatorTab
? parseInt(activeTab.substring("validator-".length), 10)
: -1;
const validatorCall = isValidatorTab
? entry.validatorCalls?.find((c) => c.intentIndex === validatorIndex)
: null;
const validatorComponents = validatorCall
? getComponents(validatorCall.prompt, "world")
: [];
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[750px] sm:max-w-[750px] h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
<DialogContent className="max-w-187.5 sm:max-w-187.5 h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
<DialogHeader className="px-6 pt-5 pb-4 border-b">
<DialogTitle className="text-lg">
Raw Prompts & Token Usage ({entry.entityName})
@@ -210,244 +78,78 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
onTabChange={setActiveTab}
hasActor={!!entry.rawPrompt}
hasDecoder={!!entry.decoderPrompt}
validatorCalls={
entry.validatorCalls?.map((c) => ({
intentIndex: c.intentIndex,
intentContent: c.intentContent,
})) || []
}
/>
<div className="overflow-y-auto flex-1 p-5">
{activeTab === "actor" && entry.rawPrompt && (
<div className="flex flex-col gap-4">
{entry.usage ? (
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
<strong>LLM Instance:</strong>{" "}
<span>{entry.usage.providerInstanceName || "Default"}</span>
{entry.usage.modelName && (
<span> ({entry.usage.modelName})</span>
)}
</div>
) : (
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm italic text-muted-foreground">
No LLM token usage (Player turn used fixed prose).
</div>
)}
{scaledActorBreakdown && (
<div>
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
<span className="font-semibold">
Input Prompt Breakdown
</span>
<span>
Total Input Tokens: <strong>{actorUsedTokens}</strong>
{actorMaxContext > 0 ? (
<span>
{" "}
/ {actorMaxContext} (
{actorUsagePctOfContext.toFixed(1)}% used)
</span>
) : (
<span> (infinite context)</span>
)}
</span>
</div>
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
{scaledActorBreakdown.map((item, idx) => {
const displayPct =
actorMaxContext > 0
? (item.tokens / actorMaxContext) * 100
: item.relativePct;
return (
<div
key={idx}
className={`h-full transition-all duration-300 ${
item.type === "system"
? "bg-blue-500"
: item.type === "world"
? "bg-emerald-500"
: item.type === "memories"
? "bg-purple-500"
: "bg-amber-500"
}`}
style={{ width: `${item.pct}%` }}
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
/>
);
})}
{isActorAbsolute && (
<div
className="bg-white h-full"
style={{ width: `${100 - actorUsagePctOfContext}%` }}
title={`Available: ${actorMaxContext - actorUsedTokens} tokens (${(100 - actorUsagePctOfContext).toFixed(1)}% remaining)`}
/>
)}
</div>
<Accordion type="multiple">
{scaledActorBreakdown.map((item, idx) => {
const displayPct =
actorMaxContext > 0
? (item.tokens / actorMaxContext) * 100
: item.relativePct;
return (
<AccordionItem key={idx} value={String(idx)}>
<AccordionTrigger className="text-sm">
<span
className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
item.type === "system"
? "bg-blue-500"
: item.type === "world"
? "bg-emerald-500"
: item.type === "memories"
? "bg-purple-500"
: "bg-amber-500"
}`}
/>
{item.label}: <strong>{item.tokens}</strong> tokens
({displayPct.toFixed(0)}%)
</AccordionTrigger>
<AccordionContent>
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground">
{item.content}
</pre>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
)}
{entry.usage && (
<div>
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
<span className="font-semibold">LLM Output</span>
<span>
Total Output Tokens:{" "}
<strong>{entry.usage.outputTokens}</strong>
</span>
</div>
<div className="rounded border-2">
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground">
{entry.narrativeProse}
</pre>
</div>
</div>
)}
</div>
<PromptAnalyzer
components={actorComponents}
inputTokens={entry.usage?.inputTokens || 0}
maxContext={
entry.usage?.maxContext !== undefined
? entry.usage.maxContext
: 32768
}
modelName={entry.usage?.modelName}
providerInstanceName={entry.usage?.providerInstanceName}
outputLabel="LLM Output (Narrative Prose)"
outputText={entry.narrativeProse}
outputTokens={entry.usage?.outputTokens}
/>
)}
{activeTab === "decoder" && entry.decoderPrompt && (
<div className="flex flex-col gap-4">
{entry.decoderUsage && (
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
<strong>LLM Instance:</strong>{" "}
<span>
{entry.decoderUsage.providerInstanceName || "Default"}
</span>
{entry.decoderUsage.modelName && (
<span> ({entry.decoderUsage.modelName})</span>
)}
</div>
<PromptAnalyzer
components={decoderComponents}
inputTokens={entry.decoderUsage?.inputTokens || 0}
maxContext={
entry.decoderUsage?.maxContext !== undefined
? entry.decoderUsage.maxContext
: 32768
}
modelName={entry.decoderUsage?.modelName}
providerInstanceName={entry.decoderUsage?.providerInstanceName}
outputLabel="LLM Output (Decoded Intent Sequence)"
outputText={JSON.stringify(
entry.decodedIntents || entry.intents,
null,
2,
)}
outputTokens={entry.decoderUsage?.outputTokens}
/>
)}
{scaledDecoderBreakdown && (
<div>
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
<span className="font-semibold">
Input Prompt Breakdown
</span>
<span>
Total Input Tokens: <strong>{decoderUsedTokens}</strong>
{decoderMaxContext > 0 ? (
<span>
{" "}
/ {decoderMaxContext} (
{decoderUsagePctOfContext.toFixed(1)}% used)
</span>
) : (
<span> (infinite context)</span>
)}
</span>
</div>
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
{scaledDecoderBreakdown.map((item, idx) => {
const displayPct =
decoderMaxContext > 0
? (item.tokens / decoderMaxContext) * 100
: item.relativePct;
return (
<div
key={idx}
className={`h-full transition-all duration-300 ${
item.type === "system"
? "bg-blue-500"
: item.type === "world"
? "bg-emerald-500"
: item.type === "memories"
? "bg-purple-500"
: "bg-amber-500"
}`}
style={{ width: `${item.pct}%` }}
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
/>
);
})}
{isDecoderAbsolute && (
<div
className="bg-white h-full"
style={{ width: `${100 - decoderUsagePctOfContext}%` }}
title={`Available: ${decoderMaxContext - decoderUsedTokens} tokens (${(100 - decoderUsagePctOfContext).toFixed(1)}% remaining)`}
/>
)}
</div>
<Accordion type="multiple">
{scaledDecoderBreakdown.map((item, idx) => {
const displayPct =
decoderMaxContext > 0
? (item.tokens / decoderMaxContext) * 100
: item.relativePct;
return (
<AccordionItem key={idx} value={String(idx)}>
<AccordionTrigger className="text-sm">
<span
className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
item.type === "system"
? "bg-blue-500"
: item.type === "world"
? "bg-emerald-500"
: item.type === "memories"
? "bg-purple-500"
: "bg-amber-500"
}`}
/>
{item.label}: <strong>{item.tokens}</strong> tokens
({displayPct.toFixed(0)}%)
</AccordionTrigger>
<AccordionContent>
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground">
{item.content}
</pre>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
)}
{validatorCall && validatorCall.prompt && (
<PromptAnalyzer
components={validatorComponents}
inputTokens={validatorCall.usage?.inputTokens || 0}
maxContext={
validatorCall.usage?.maxContext !== undefined
? validatorCall.usage.maxContext
: 32768
}
modelName={validatorCall.usage?.modelName}
providerInstanceName={validatorCall.usage?.providerInstanceName}
outputLabel={`LLM Output`}
outputText={JSON.stringify(validatorCall.response, null, 2)}
outputTokens={validatorCall.usage?.outputTokens}
/>
)}
{entry.decoderUsage && (
<div>
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
<span className="font-semibold">LLM Output</span>
<span>
Total Output Tokens:{" "}
<strong>{entry.decoderUsage.outputTokens}</strong>
</span>
</div>
<div className="rounded border-2">
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground">
{JSON.stringify(entry.intents, null, 2)}
</pre>
</div>
</div>
)}
{validatorCall && !validatorCall.prompt && (
<div className="flex flex-col items-center justify-center border border-dashed rounded-lg bg-muted/20 text-muted-foreground p-8 my-6">
<span className="text-sm font-semibold mb-2 text-foreground">
Bypassed LLM Validation
</span>
<p className="text-xs text-center text-muted-foreground max-w-md">
{validatorCall.response.reason}
</p>
</div>
)}
</div>

View File

@@ -1,10 +1,11 @@
"use client";
interface PromptSwitcherProps {
activeTab: "actor" | "decoder";
onTabChange: (tab: "actor" | "decoder") => void;
activeTab: string;
onTabChange: (tab: string) => void;
hasActor: boolean;
hasDecoder: boolean;
validatorCalls?: { intentIndex: number; intentContent: string }[];
}
export function PromptSwitcher({
@@ -12,32 +13,67 @@ export function PromptSwitcher({
onTabChange,
hasActor,
hasDecoder,
validatorCalls = [],
}: PromptSwitcherProps) {
return (
<div className="flex items-center justify-center gap-4 border-b bg-muted/50 px-5 py-4">
<button
onClick={() => onTabChange("actor")}
disabled={!hasActor}
className={`flex h-14 w-40 items-center justify-center border-2 text-sm font-medium transition-all ${
activeTab === "actor"
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border/30 bg-card text-foreground hover:border-primary/50"
} disabled:cursor-not-allowed disabled:opacity-40`}
>
Actor Prompt
</button>
<span className="text-xl text-muted-foreground"></span>
<button
onClick={() => onTabChange("decoder")}
disabled={!hasDecoder}
className={`flex h-14 w-44 items-center justify-center border-2 text-sm font-medium transition-all ${
activeTab === "decoder"
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border/30 bg-card text-foreground hover:border-primary/50"
} disabled:cursor-not-allowed disabled:opacity-40`}
>
Intent Decoder
</button>
<div className="flex items-center justify-center gap-4 border-b bg-muted/40 px-6 py-5 overflow-x-auto">
{/* Primary Pipeline (Linear flow to the left) */}
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => onTabChange("actor")}
disabled={!hasActor}
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all ${
activeTab === "actor"
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:border-primary/50"
} disabled:cursor-not-allowed disabled:opacity-40 rounded`}
>
Actor Prompt
</button>
<span className="text-lg text-muted-foreground"></span>
<button
onClick={() => onTabChange("decoder")}
disabled={!hasDecoder}
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all ${
activeTab === "decoder"
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:border-primary/50"
} disabled:cursor-not-allowed disabled:opacity-40 rounded`}
>
Intent Decoder
</button>
</div>
{/* Branching Validator Column to the right of Intent Decoder */}
{validatorCalls.length > 0 && (
<div className="flex items-center gap-3 shrink-0">
<span className="text-lg text-muted-foreground"></span>
<div className="flex flex-col gap-2 pl-3">
<div className="flex flex-col gap-2">
{validatorCalls.map((call) => {
const tabKey = `validator-${call.intentIndex}`;
return (
<button
key={tabKey}
onClick={() => onTabChange(tabKey)}
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all rounded ${
activeTab === tabKey
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:border-primary/50"
}`}
title={call.intentContent}
>
LLM Validator (Intent #{call.intentIndex})
</button>
);
})}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -21,7 +21,7 @@ export function ScenarioCard({
return (
<div
onClick={disabled ? undefined : onClick}
className={`flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm transition-all ${
className={`shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm transition-all ${
disabled
? "opacity-50 cursor-not-allowed filter grayscale"
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"

View File

@@ -0,0 +1,62 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded border border-border/30 bg-card p-4 text-sm shadow-[2px_2px_0_0_var(--border)] flex flex-col md:flex-row md:items-center gap-3 justify-between",
className,
)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn(
"font-head font-bold leading-none tracking-tight text-foreground",
className,
)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-xs text-muted-foreground mt-1 flex-1 leading-relaxed md:mt-0",
className,
)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
const AlertAction = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("shrink-0 flex items-center mt-2 md:mt-0 md:ml-4", className)}
{...props}
/>
));
AlertAction.displayName = "AlertAction";
export { Alert, AlertTitle, AlertDescription, AlertAction };

View File

@@ -22,6 +22,7 @@ const buttonVariants = cva(
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3 text-xs",
xs: "h-7 px-2.5 text-xs",
lg: "h-11 px-8 text-base",
icon: "h-10 w-10",
},

View File

@@ -0,0 +1,299 @@
"use client";
import * as React from "react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"cn-menu-target cn-menu-translucent group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+var(--spacing-7,1.75rem))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded border-2 bg-input bg-clip-padding px-2.5 py-1 text-sm shadow-sm transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-primary has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(var(--spacing,0.25rem)*5.25)] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};

View File

@@ -0,0 +1,173 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded border-2 bg-input shadow-sm transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:outline-2 has-[[data-slot=input-group-control]:focus-visible]:outline-offset-2 has-[[data-slot=input-group-control]:focus-visible]:outline-primary has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
render,
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
render?: React.ReactElement;
}) {
if (render) {
return React.cloneElement(render, {
className: cn(
inputGroupButtonVariants({ size }),
(render.props as Record<string, unknown>)?.className as
string | undefined,
className,
),
type,
...props,
} as Record<string, unknown> as React.HTMLAttributes<HTMLElement>);
}
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none outline-none focus-visible:outline-none disabled:bg-transparent aria-invalid:outline-none dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -1,7 +1,6 @@
export interface IntentInfo {
type: string;
description: string;
selfDescription?: string;
content: string;
modifiers: string[];
targetIds: string[];
isValid?: boolean;
@@ -9,16 +8,25 @@ export interface IntentInfo {
minutesToAdvance?: number;
}
export interface LogEntry {
turn: number;
entityId: string;
entityName: string;
narrativeProse: string;
intents: IntentInfo[];
timestamp: string;
rawPrompt?: {
systemPrompt: string;
userContext: string;
export interface PromptComponent {
label: string;
type: "system" | "world" | "events" | "memories" | "input" | "other";
content: string;
}
export interface PromptBreakdown {
systemPrompt: string;
userContext: string;
components?: PromptComponent[];
}
export interface ValidatorCall {
intentIndex: number;
intentContent: string;
prompt?: PromptBreakdown;
response: {
isValid: boolean;
reason: string;
};
usage?: {
inputTokens: number;
@@ -28,10 +36,39 @@ export interface LogEntry {
providerInstanceName?: string;
maxContext?: number;
};
decoderPrompt?: {
systemPrompt: string;
userContext: string;
}
export interface HandoffResult {
chunks: {
content: string;
importance: number;
quotes?: string[];
retainInBuffer?: boolean;
involvedEntityIds?: string[];
}[];
}
export interface LogEntry {
turn: number;
entityId: string;
entityName: string;
narrativeProse: string;
intents: IntentInfo[];
timestamp: string;
isHandoff?: boolean;
handoffResult?: HandoffResult;
decodedIntents?: IntentInfo[];
validatorCalls?: ValidatorCall[];
rawPrompt?: PromptBreakdown;
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
decoderPrompt?: PromptBreakdown;
decoderUsage?: {
inputTokens: number;
outputTokens: number;
@@ -47,6 +84,7 @@ export interface EntityInfo {
name: string;
isPlayer: boolean;
isAgent: boolean;
aliases?: Record<string, string>;
}
export interface WaitingContext {
@@ -68,4 +106,6 @@ export interface SimSnapshot {
entityIndex: number;
waitingEntity?: WaitingContext;
error?: string;
worldTime?: string;
currentLocation?: string;
}

View File

@@ -3,7 +3,7 @@ import type { SimSession } from "./types";
/**
* Runs the HandoffEngine for every agent entity that has accumulated enough
* buffer entries to warrant a handoff (compression to long-term memory).
* buffer entries to warrant a handoff (compression to the Memory Ledger).
*/
export async function runHandoffResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
@@ -33,11 +33,39 @@ export async function runHandoffResolution(session: SimSession): Promise<void> {
maxContext,
);
if (trigger !== "none") {
await handoffEngine.runHandoff(
const ran = await handoffEngine.runHandoff(
entity,
bufferEntries,
worldState.clock.get(),
);
if (ran) {
const lastResult = handoffEngine.lastResult;
const lastCall =
session.handoffProvider.lastCalls?.[
(session.handoffProvider.lastCalls?.length || 0) - 1
];
const info = session.entities.find((e) => e.id === entity.id);
const entityName = info?.name || entity.id;
session.log.push({
turn: session.turn,
entityId: entity.id,
entityName,
narrativeProse: `Handoff triggered for ${entityName}: memories were transferred from Cognitive Buffer to Memory Ledger`,
intents: [],
timestamp: worldState.clock.get().toISOString(),
isHandoff: true,
rawPrompt: lastResult
? {
systemPrompt: lastResult.systemPrompt || "",
userContext: lastResult.userContext || "",
components: lastResult.promptComponents,
}
: undefined,
usage: lastCall?.usage,
handoffResult: (lastResult?.response || lastCall?.response) as any,
});
}
}
}
}

View File

@@ -1,10 +1,9 @@
import {
GeminiProvider,
MockLLMProvider,
ProviderManager,
OpenRouterProvider,
GeminiEmbeddingProvider,
MockEmbeddingProvider,
ProviderManager,
buildLLMProvider,
buildEmbeddingProvider,
} from "@omnia/llm";
import type {
ILLMProvider,
@@ -40,38 +39,10 @@ export interface ProviderResolverOptions {
}
// ---------------------------------------------------------------------------
// Private builders
// Resolution logic
// ---------------------------------------------------------------------------
function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider {
if (inst.providerName === "google-genai") {
return new GeminiProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
} else if (inst.providerName === "openrouter") {
return new OpenRouterProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
return new MockLLMProvider([]);
}
function buildEmbeddingProvider(
inst: ModelProviderInstance,
): IEmbeddingProvider {
if (inst.providerName === "google-genai") {
return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
}
return new MockEmbeddingProvider(inst.modelName);
}
// ---------------------------------------------------------------------------
/**
// Public API
// ---------------------------------------------------------------------------

View File

@@ -5,11 +5,7 @@ import fs from "fs";
import { SQLiteRepository } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import {
ProviderManager,
GeminiEmbeddingProvider,
MockEmbeddingProvider,
} from "@omnia/llm";
import { ProviderManager, buildEmbeddingProvider } from "@omnia/llm";
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type { SimSnapshot } from "../simulation-types";
@@ -40,6 +36,7 @@ export class SimulationManager {
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
customName?: string,
): Promise<SimSnapshot> {
// Resolve or validate the active generative provider upfront so we can
// return a clean error snapshot before touching the filesystem.
@@ -172,7 +169,7 @@ export class SimulationManager {
bufferRepo,
ledgerRepo,
worldInstanceId,
scenarioName: scenarioJson.name,
scenarioName: customName || scenarioJson.name,
scenarioDescription: scenarioJson.description || "",
turn: 1,
maxTurns: 20,
@@ -295,6 +292,19 @@ export class SimulationManager {
return session ? this.snapshot(session) : null;
}
async rename(id: string, newName: string): Promise<SimSnapshot | null> {
let session = this.sessions.get(id);
if (!session) {
await this.load(id);
session = this.sessions.get(id);
}
if (!session) return null;
session.scenarioName = newName;
saveSession(session);
return this.snapshot(session);
}
// ---------------------------------------------------------------------------
// Simulation stepping
// ---------------------------------------------------------------------------
@@ -398,14 +408,34 @@ export class SimulationManager {
inst = ProviderManager.getActive("embedding");
}
const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY || "";
if (envKey) {
inst = {
id: "regen-env-fallback",
name: "Gemini Embed (Env)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
maxContext: 0,
};
} else {
inst = {
id: "regen-mock-fallback",
name: "Mock Embed (Fallback)",
providerName: "mock",
apiKey: "",
isActive: true,
modelName: undefined,
type: "embedding",
maxContext: 0,
};
}
}
const embeddingProvider: IEmbeddingProvider =
providerName === "google-genai"
? new GeminiEmbeddingProvider(key, modelName)
: new MockEmbeddingProvider(modelName);
const embeddingProvider: IEmbeddingProvider = buildEmbeddingProvider(inst);
for (const file of files) {
const dbPath = path.join(DATA_DIR, file);
@@ -438,6 +468,38 @@ export class SimulationManager {
// ---------------------------------------------------------------------------
private snapshot(session: SimSession): SimSnapshot {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
const hydratedEntities = session.entities.map((e) => {
const actualEntity = worldState?.getEntity(e.id);
const aliases: Record<string, string> = {};
if (actualEntity) {
for (const [targetId, alias] of actualEntity.aliases.entries()) {
aliases[targetId] = alias;
}
}
return {
...e,
aliases,
};
});
// Get current location from the waiting entity
let currentLocation: string | undefined;
if (
worldState &&
session.entityIndex >= 0 &&
session.entityIndex < session.entities.length
) {
const currentEntityInfo = session.entities[session.entityIndex];
const actualEntity = worldState.getEntity(currentEntityInfo.id);
if (actualEntity?.locationId) {
const location = worldState.getLocation(actualEntity.locationId);
if (location) {
currentLocation = location.id;
}
}
}
return {
id: session.worldInstanceId,
status: session.status,
@@ -445,11 +507,13 @@ export class SimulationManager {
maxTurns: session.maxTurns,
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
entities: session.entities,
entities: hydratedEntities,
log: session.log,
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
worldTime: worldState?.clock.get().toISOString(),
currentLocation,
};
}
}

View File

@@ -10,6 +10,7 @@ import type {
IntentInfo,
LogEntry,
WaitingContext,
ValidatorCall,
} from "../simulation-types";
// ---------------------------------------------------------------------------
@@ -49,17 +50,18 @@ async function processIntents(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
worldState: any,
session: SimSession,
): Promise<IntentInfo[]> {
): Promise<{ intentInfos: IntentInfo[]; validatorCalls: ValidatorCall[] }> {
const intentInfos: IntentInfo[] = [];
const validatorCalls: ValidatorCall[] = [];
for (const intent of intents) {
for (let i = 0; i < intents.length; i++) {
const intent = intents[i];
const outcome = await session.architect.processIntent(worldState, intent);
const ts = worldState.clock.get().toISOString();
intentInfos.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
@@ -67,6 +69,50 @@ async function processIntents(
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
});
if (intent.type === "action" && session.architect.validator.lastResult) {
const lastResult = session.architect.validator.lastResult;
let usage = undefined;
if (
session.validatorProvider.lastCalls &&
session.validatorProvider.lastCalls.length > 0
) {
const valCall =
session.validatorProvider.lastCalls[
session.validatorProvider.lastCalls.length - 1
];
usage = valCall.usage;
}
validatorCalls.push({
intentIndex: i,
intentContent: intent.content,
prompt: {
systemPrompt: lastResult.systemPrompt || "",
userContext: lastResult.userContext || "",
components: lastResult.components,
},
response: {
isValid: outcome.isValid,
reason: outcome.reason,
},
usage,
});
} else {
const reason =
intent.type === "dialogue"
? "Dialogue intents represent verbal/communication actions and are automatically valid."
: "Monologue/thought intents represent internal reflections and bypass validation.";
validatorCalls.push({
intentIndex: i,
intentContent: intent.content,
response: {
isValid: true,
reason: outcome.reason || reason,
},
});
}
const actorEntry = buildBufferEntryForIntent(intent, ts, entity.locationId);
if (intent.type === "action") {
actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason };
@@ -100,7 +146,7 @@ async function processIntents(
}
}
return intentInfos;
return { intentInfos, validatorCalls };
}
// ---------------------------------------------------------------------------
@@ -166,6 +212,11 @@ export async function processNpcTurn(
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: result.systemPrompt || "",
userContext: result.userContext || "",
components: result.promptComponents,
},
};
if (
@@ -176,10 +227,6 @@ export async function processNpcTurn(
session.actorProvider.lastCalls[
session.actorProvider.lastCalls.length - 1
];
entry.rawPrompt = {
systemPrompt: actorCall.systemPrompt,
userContext: actorCall.userContext,
};
entry.usage = actorCall.usage;
}
@@ -191,20 +238,49 @@ export async function processNpcTurn(
session.decoderProvider.lastCalls[
session.decoderProvider.lastCalls.length - 1
];
const proseHeader = "=== NARRATIVE PROSE ===";
const userContext = decoderCall.userContext;
const idx = userContext.indexOf(proseHeader);
let contextStr = userContext;
let proseStr = "";
if (idx !== -1) {
contextStr = userContext.substring(0, idx).trim();
proseStr = userContext.substring(idx).trim();
}
entry.decoderPrompt = {
systemPrompt: decoderCall.systemPrompt,
userContext: decoderCall.userContext,
components: [
{
label: "System Prompt",
type: "system",
content: decoderCall.systemPrompt,
},
{ label: "Decoder Context", type: "world", content: contextStr },
{ label: "Narrative Prose", type: "input", content: proseStr },
],
};
entry.decoderUsage = decoderCall.usage;
}
entry.intents = await processIntents(
const { intentInfos, validatorCalls } = await processIntents(
result.intents.intents,
info.id,
entity,
worldState,
session,
);
entry.intents = intentInfos;
entry.validatorCalls = validatorCalls;
entry.decodedIntents = result.intents.intents.map((intent) => ({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
}));
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
@@ -244,8 +320,9 @@ export async function executePlayerAction(
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: ctx.systemPrompt,
userContext: ctx.userContext,
systemPrompt: result.systemPrompt || ctx.systemPrompt,
userContext: result.userContext || ctx.userContext,
components: result.promptComponents,
},
};
@@ -257,20 +334,45 @@ export async function executePlayerAction(
session.decoderProvider.lastCalls[
session.decoderProvider.lastCalls.length - 1
];
const proseHeader = "=== NARRATIVE PROSE ===";
const userContext = call.userContext;
const idx = userContext.indexOf(proseHeader);
let contextStr = userContext;
let proseStr = "";
if (idx !== -1) {
contextStr = userContext.substring(0, idx).trim();
proseStr = userContext.substring(idx).trim();
}
entry.decoderPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
components: [
{ label: "System Prompt", type: "system", content: call.systemPrompt },
{ label: "Decoder Context", type: "world", content: contextStr },
{ label: "Narrative Prose", type: "input", content: proseStr },
],
};
entry.decoderUsage = call.usage;
}
entry.intents = await processIntents(
const { intentInfos, validatorCalls: playerValCalls } = await processIntents(
result.intents.intents,
ctx.entityId,
entity,
worldState,
session,
);
entry.intents = intentInfos;
entry.validatorCalls = playerValCalls;
entry.decodedIntents = result.intents.intents.map((intent) => ({
type: intent.type,
content: intent.content,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
}));
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);

View File

@@ -4,3 +4,41 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatSimDate(isoString: string): string {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
} catch {
return isoString;
}
}
export function formatSimTimeHM(isoString: string): string {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const hh = String(d.getUTCHours());
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
} catch {
return isoString;
}
}
export function getClockIcon(isoString: string): string {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return "/clock_day_icon.png";
const hour = d.getUTCHours();
return hour >= 6 && hour < 18
? "/clock_day_icon.png"
: "/clock_night_icon.png";
} catch {
return "/clock_day_icon.png";
}
}

View File

@@ -72,16 +72,37 @@
],
"initialMemories": [
{
"id": "alpha-wake",
"timestamp": "2026-07-09T07:58:00.000Z",
"id": "ab3f29d2-cf11-4111-9a99-b13c126d123e",
"timestamp": "2026-07-01T07:58:00.000Z",
"locationId": "white-room",
"intent": {
"type": "monologue",
"originalText": "I didn't have a choice. I would have been sent to jail if I hadn't agreed to do this experiement.",
"description": "",
"content": "I didn't have a choice. I would have been sent to jail if I hadn't agreed to do this experiment.",
"actorId": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
"targetIds": []
}
},
{
"id": "10ak29d2-as11-9811-9a99-b13c126d123e",
"timestamp": "2026-07-09T06:00:00.000Z",
"locationId": "white-room",
"intent": {
"type": "action",
"content": "entity@7c9b83b3-8cfb-4e89-8d77-626a5757d591[I] woke up today in the room and saw entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[another man]!",
"actorId": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
"targetIds": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
}
},
{
"id": "zz3f29d2-as11-9811-9a99-b13c126d123e",
"timestamp": "2026-07-09T07:58:00.000Z",
"locationId": "white-room",
"intent": {
"type": "action",
"content": "entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[I] wake up from my sleep.",
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
"targetIds": []
}
}
]
},
@@ -119,13 +140,12 @@
],
"initialMemories": [
{
"id": "beta-wake",
"timestamp": "2026-07-09T07:58:30.000Z",
"id": "zx1f29d2-cf11-4111-9a99-b13c126d123e",
"timestamp": "2026-07-09T07:58:00.000Z",
"locationId": "white-room",
"intent": {
"type": "action",
"originalText": "Why can't I remember anything before the research agreement. It's like my memory was erased.",
"description": "",
"content": "entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[I] wake up in an unfamiliar place.",
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
"targetIds": []
}

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
omnia-gui:
build:
context: .
dockerfile: apps/gui/Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
volumes:
- omnia-data:/app/apps/gui/data
volumes:
omnia-data:

2
docs
View File

@@ -1 +1 @@
web/docs/src
web/docs/src/content/docs

View File

@@ -19,7 +19,8 @@
"watch": "tsc -b --watch",
"test": "vitest run --project unit",
"test:watch": "vitest --project unit",
"test:evals": "vitest run --project evals"
"test:evals": "vitest run --project evals",
"setup-provider": "node packages/llm/dist/bin/setup-provider.js"
},
"keywords": [],
"author": "sortedcord",
@@ -27,7 +28,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.13.0",
"version": "11.15.1",
"onFail": "download"
}
},
@@ -47,9 +48,15 @@
"zod": "^4.4.3"
},
"dependencies": {
"@langchain/anthropic": "^0.3.11",
"@langchain/deepseek": "^1.1.5",
"@langchain/google-genai": "^2.2.0",
"@langchain/groq": "^1.3.1",
"@langchain/ollama": "^0.2.3",
"@langchain/openai": "^0.3.17",
"@langchain/openrouter": "^0.4.3",
"@types/node": "^20.19.43",
"compromise": "^14.16.0",
"dotenv": "^17.4.2"
}
}

View File

@@ -11,6 +11,7 @@
"@omnia/intent": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/memory": "workspace:*",
"@omnia/voice": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -4,7 +4,6 @@ import {
WorldState,
naturalizeTime,
serializeSubjectiveWorldState,
resolveAlias,
} from "@omnia/core";
import {
BufferEntry,
@@ -13,6 +12,8 @@ import {
LedgerEntry,
LedgerRepository,
} from "@omnia/memory";
import { hydrate } from "@omnia/voice";
import { PromptComponent, IPromptBuilder, PromptBreakdown } from "@omnia/llm";
/**
* Zod schema for the structured response expected from the actor LLM.
@@ -33,17 +34,19 @@ export type ActorResponse = z.infer<typeof ActorResponseSchema>;
*
* The prompt is strictly epistemically bounded: the entity only sees what
* it is allowed to see (public attributes + private attributes explicitly
* ACL'd to it), its own recent memory buffer, and the entities co-located
* ACL'd to it), its own Cognitive Buffer, and the entities co-located
* with it. System UUIDs are surfaced as subjective aliases.
*/
export class ActorPromptBuilder {
export class ActorPromptBuilder implements IPromptBuilder<
[WorldState, Entity]
> {
/**
* @param bufferRepo Used to fetch the actor's recent memory. Optional —
* @param bufferRepo Used to fetch the actor's Cognitive Buffer. Optional —
* if absent, the memory section is omitted.
* @param ledgerRepo Used to fetch long-term memories. Optional.
* @param memoryLimit Maximum number of recent buffer entries to inject.
* @param ledgerRepo Used to fetch Memory Ledger entries. Optional.
* @param memoryLimit Maximum number of recent Cognitive Buffer entries to inject.
* Defaults to 20.
* @param ledgerLimit Maximum number of long-term memories to retrieve.
* @param ledgerLimit Maximum number of Memory Ledger entries to retrieve.
* Defaults to 5.
*/
constructor(
@@ -56,13 +59,20 @@ export class ActorPromptBuilder {
/**
* Assembles the system prompt and user context for a given entity.
*/
build(
worldState: WorldState,
entity: Entity,
): { systemPrompt: string; userContext: string } {
/**
* Assembles the system prompt and user context for a given entity.
*/
/**
* Assembles the system prompt and user context for a given entity.
*/
build(worldState: WorldState, entity: Entity): PromptBreakdown {
const systemPrompt = this.buildSystemPrompt();
const userContext = this.buildUserContext(worldState, entity);
return { systemPrompt, userContext };
const { userContext, components } = this.buildUserContext(
worldState,
entity,
systemPrompt,
);
return { systemPrompt, userContext, components };
}
private buildSystemPrompt(): string {
@@ -76,30 +86,32 @@ Your output is a short block of narrative prose describing what your character d
Guidelines:
- Always write in the first person
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment or your surroundings, or other characters' actions.
- Refer to other entities by the subjective names/aliases that you refer to them as.
- Keep your prose vivid but concise. Write it in natural narrative order.
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
- Never speak or act on another entity's behalf. You only control your own character.
- Stay strictly within what your character knows. Do not invent knowledge that doesn't exist or act on it.
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying.
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate the environment or your surroundings, or other characters' actions.
- Be clear about who or what you are interacting with.
".
`.trim();
}
private buildUserContext(worldState: WorldState, entity: Entity): string {
const sections: string[] = [];
private buildUserContext(
worldState: WorldState,
entity: Entity,
systemPrompt: string,
): {
userContext: string;
components: PromptComponent[];
} {
const now = worldState.clock.get();
// --- Subjective present time ---
sections.push(
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
);
// --- Subjective world state (self + perceived entities + co-location) ---
sections.push(
`=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`,
);
// --- Subjective present time & world state ---
const momentStr = `=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`;
const perceivedStr = `=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`;
const worldInfo = `${momentStr}\n\n${perceivedStr}`;
// Fetch recent buffer entries once
let recentEntries: BufferEntry[] = [];
@@ -111,27 +123,55 @@ Guidelines:
}
}
// --- Recent memory ---
const memorySection = this.buildMemorySection(entity, recentEntries, now);
if (memorySection) {
sections.push(memorySection);
}
// --- Recalled Long-Term memory ---
// --- Recalled Memory Ledger ---
const ledgerSection = this.buildLedgerSection(
worldState,
entity,
recentEntries,
now,
);
if (ledgerSection) {
sections.push(ledgerSection);
const memoryLedger = ledgerSection || "";
// --- Cognitive Buffer ---
const memorySection = this.buildCognitiveBufferSection(
entity,
recentEntries,
now,
);
const cognitiveBuffer = memorySection || "";
// Assemble final user context
const parts: string[] = [worldInfo];
if (memoryLedger) parts.push(memoryLedger);
if (cognitiveBuffer) parts.push(cognitiveBuffer);
const userContext = parts.join("\n\n");
const components: PromptComponent[] = [
{ label: "System Prompt", type: "system", content: systemPrompt },
{ label: "World Info", type: "world", content: worldInfo },
];
if (memoryLedger) {
components.push({
label: "Memory Ledger",
type: "memories",
content: memoryLedger,
});
}
if (cognitiveBuffer) {
components.push({
label: "Cognitive Buffer",
type: "events",
content: cognitiveBuffer,
});
}
return sections.join("\n\n");
return {
userContext,
components,
};
}
private buildMemorySection(
private buildCognitiveBufferSection(
entity: Entity,
entries: BufferEntry[],
now: Date,
@@ -139,7 +179,7 @@ Guidelines:
if (!this.bufferRepo) return null;
if (entries.length === 0) {
return `=== RECENT EVENTS ===\n(No recent events recorded.)`;
return `=== COGNITIVE BUFFER ===\n(No entries recorded.)`;
}
const recent = entries.slice(-this.memoryLimit);
@@ -147,9 +187,16 @@ Guidelines:
let currentGroup: string | null = null;
for (const entry of recent) {
const serialized = serializeSubjectiveBufferEntry(entry, entity);
let serialized = serializeSubjectiveBufferEntry(entry, entity);
const when = naturalizeTime(now, new Date(entry.timestamp));
if (
entry.intent.actorId === entity.id &&
entry.intent.type === "dialogue"
) {
serialized = `I said: ${serialized}`;
}
if (when !== currentGroup) {
currentGroup = when;
const header = when.charAt(0).toUpperCase() + when.slice(1);
@@ -159,7 +206,7 @@ Guidelines:
groupedLines.push(` - ${serialized}`);
}
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`;
return `=== COGNITIVE BUFFER ===\n${groupedLines.join("\n")}`;
}
private buildLedgerSection(
@@ -239,12 +286,7 @@ Guidelines:
for (const entry of recalled) {
const when = naturalizeTime(now, new Date(entry.timestamp));
let content = entry.content;
// Resolve system IDs to subjective aliases in the content
for (const targetId of entry.involvedEntityIds) {
const alias = resolveAlias(entity, targetId);
content = content.replace(new RegExp(targetId, "g"), alias);
}
let content = hydrate(entry.content, entity);
if (entry.locationId) {
content += ` (at ${entry.locationId})`;
}
@@ -263,6 +305,6 @@ Guidelines:
}
}
return `=== YOUR MEMORIES ===\n${groupedLines.join("\n")}`;
return `=== MEMORY LEDGER ===\n${groupedLines.join("\n")}`;
}
}

View File

@@ -1,5 +1,5 @@
import { Entity, WorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import { ILLMProvider, PromptComponent } from "@omnia/llm";
import { BufferEntry, BufferRepository, LedgerRepository } from "@omnia/memory";
import { Intent, IntentDecoder, IntentSequence } from "@omnia/intent";
import {
@@ -54,6 +54,9 @@ export interface ActorTurnResult {
narrativeProse: string;
/** The decoded intent sequence (split/classified from the prose). */
intents: IntentSequence;
systemPrompt?: string;
userContext?: string;
promptComponents?: PromptComponent[];
}
/**
@@ -76,7 +79,7 @@ export class ActorAgent {
constructor(
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
bufferRepo?: BufferRepository,
private bufferRepo?: BufferRepository,
ledgerRepo?: LedgerRepository,
memoryLimit?: number,
generator?: IActorProseGenerator,
@@ -116,7 +119,7 @@ export class ActorAgent {
);
}
const { systemPrompt, userContext } = this.promptBuilder.build(
const { systemPrompt, userContext, components } = this.promptBuilder.build(
worldState,
entity,
);
@@ -127,15 +130,27 @@ export class ActorAgent {
userContext,
);
const recentEntries = this.bufferRepo
? this.bufferRepo.listForOwner(entity.id)
: [];
const recentIntents = recentEntries
.filter((e) => e.intent.actorId !== entity.id)
.slice(-3)
.map((e) => e.intent);
const intents = await this.decoder.decode(
worldState,
entity.id,
narrativeProse,
recentIntents,
);
return {
narrativeProse,
intents,
systemPrompt,
userContext,
promptComponents: components,
};
}
}

View File

@@ -4,7 +4,7 @@ import { WorldState, Entity } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { ActorPromptBuilder } from "../src/actor-prompt-builder";
describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
describe("ActorPromptBuilder with Memory Ledger Integration", () => {
let db: Database.Database;
let bufferRepo: BufferRepository;
let ledgerRepo: LedgerRepository;
@@ -31,7 +31,7 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
db.close();
});
it("should inject both recent memory and recalled long-term memory with subjective aliases resolved", () => {
it("should inject both Cognitive Buffer and recalled Memory Ledger entries with subjective aliases resolved", () => {
const world = new WorldState(
"world-123",
new Date("2024-01-10T12:00:00.000Z"),
@@ -55,19 +55,19 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
type: "dialogue",
actorId: "alice",
targetIds: ["bob"],
originalText: "Hello there",
description: "Alice greets Bob",
content: "entity@alice[I] say 'Hello there' to entity@bob[Bob]",
modifiers: [],
},
});
// 2. Populate ledger repository (long-term memory)
// 2. Populate ledger repository (Memory Ledger)
ledgerRepo.save({
id: "ledger1",
ownerId: "alice",
timestamp: "2024-01-08T12:00:00.000Z", // 2 days ago
locationId: "tavern",
involvedEntityIds: ["bob"],
content: "alice met bob at the tavern.",
content: "entity@alice[Alice] met entity@bob[bob] at the tavern.",
quotes: ["I am a ranger."],
importance: 9,
embedding: [],
@@ -76,14 +76,14 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
const { userContext } = builder.build(world, alice);
// Check recent memory exists
expect(userContext).toContain("=== RECENT EVENTS ===");
expect(userContext).toContain("Alice greets Bob");
// Check Cognitive Buffer exists
expect(userContext).toContain("=== COGNITIVE BUFFER ===");
expect(userContext).toContain("I said: I say 'Hello there' to Strider");
// Check long-term memory exists
expect(userContext).toContain("=== YOUR MEMORIES ===");
// Bob should be resolved to Strider in the ledger content
expect(userContext).toContain("alice met Strider at the tavern.");
// Check Memory Ledger exists
expect(userContext).toContain("=== MEMORY LEDGER ===");
// Bob should be resolved to Strider, and alice to I in the ledger content
expect(userContext).toContain("I met Strider at the tavern.");
expect(userContext).toContain('Quote: "I am a ranger."');
});
@@ -98,7 +98,7 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
const { userContext } = builder.build(world, alice);
expect(userContext).toContain("=== RECENT EVENTS ===");
expect(userContext).not.toContain("=== YOUR MEMORIES ===");
expect(userContext).toContain("=== COGNITIVE BUFFER ===");
expect(userContext).not.toContain("=== MEMORY LEDGER ===");
});
});

View File

@@ -9,6 +9,7 @@
{ "path": "../core" },
{ "path": "../intent" },
{ "path": "../llm" },
{ "path": "../memory" }
{ "path": "../memory" },
{ "path": "../voice" }
]
}

View File

@@ -10,6 +10,7 @@
"@omnia/core": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/voice": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -9,7 +9,7 @@ export interface ProcessResult extends ValidationResult {
}
export class Architect {
private validator: LLMValidator;
public validator: LLMValidator;
private timeDeltaGenerator: TimeDeltaGenerator;
constructor(
@@ -47,22 +47,22 @@ export class Architect {
* Processes, validates, generates deltas, applies them to the world state,
* and persists the changes to the database.
*
* "monologue" intents are internal thoughts — they bypass validation and
* "monologue" and "thought" intents are internal thoughts — they bypass validation and
* time-delta generation entirely: the clock does not advance, the world
* state is not mutated or persisted. The caller is responsible for writing
* the monologue to the actor's memory buffer.
* the monologue/thought to the actor's Cognitive Buffer.
*/
async processIntent(
worldState: WorldState,
intent: Intent,
): Promise<ProcessResult> {
// 0. Monologue intents are purely internal — short-circuit before any
// 0. Monologue/thought intents are purely internal — short-circuit before any
// validation or world mutation.
if (intent.type === "monologue") {
if (intent.type === "monologue" || intent.type === "thought") {
return {
isValid: true,
reason:
"Monologue intent bypasses validation (internal thought, not perceivable).",
"Monologue/thought intent bypasses validation (internal thought, not perceivable).",
timeDelta: {
minutesToAdvance: 0,
explanation: "Internal thought — no time elapsed.",

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import { Intent } from "@omnia/intent";
import { hydrateObjective } from "@omnia/voice";
export const TimeDeltaSchema = z.object({
minutesToAdvance: z.number().int().nonnegative(),
@@ -26,10 +27,11 @@ export class TimeDeltaGenerator implements IDeltaGenerator<TimeDelta> {
explanation: "Dialogue action; 1 minute granted for quick exchange.",
};
}
if (intent.type === "monologue") {
if (intent.type === "monologue" || intent.type === "thought") {
return {
minutesToAdvance: 0,
explanation: "Monologue action; no time advanced for internal thought.",
explanation:
"Monologue/thought action; no time advanced for internal thought.",
};
}
@@ -45,6 +47,8 @@ Return a structured JSON object containing:
- "explanation": a brief explanation of why this amount of time is appropriate.
`.trim();
const objectiveContent = hydrateObjective(intent.content, worldState);
const userContext = `
=== CURRENT WORLD STATE ===
Current Time: ${worldState.clock.get().toISOString()}
@@ -54,8 +58,7 @@ ${serializeObjectiveWorldState(worldState)}
=== ACTION ===
Actor ID: ${intent.actorId}
Type: ${intent.type}
Description: "${intent.description}"
Original Text: "${intent.originalText}"
Content: "${objectiveContent}"
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
`.trim();

View File

@@ -1,3 +1,4 @@
export * from "./llm-validator.js";
export * from "./llm-validator-prompt-builder.js";
export * from "./architect.js";
export * from "./delta.js";

View File

@@ -0,0 +1,59 @@
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
import { Intent } from "@omnia/intent";
import { hydrateObjective } from "@omnia/voice";
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
/**
* Prompt builder for the LLM Validator (World Architect).
* Separates prompt generation, structure, and component breakdowns.
*/
export class LLMValidatorPromptBuilder implements IPromptBuilder<
[WorldState, Intent]
> {
build(worldState: WorldState, intent: Intent): PromptBreakdown {
const serializedWorld = serializeObjectiveWorldState(worldState);
const systemPrompt = `
You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game.
Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world.
Exempt dialogue or speech actions from validation (consider them always valid).
Enforce logical boundaries such as:
- Spatial boundaries (an actor cannot grab an object in another location unless they are there).
- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it).
- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so).
- State/Attribute constraints.
- An actor can perform actions on themselves as long as it follows the boundaries stated above.
You must respond with a JSON object containing:
- "isValid": boolean indicating if the action is possible/allowed.
- "reason": a very short explanation of why the action is allowed or denied.
`.trim();
const objectiveContent = hydrateObjective(intent.content, worldState);
const worldStateSection = `=== CURRENT WORLD STATE ===\nCurrent Time: ${worldState.clock.get().toISOString()}\nEntities & Attributes:\n${serializedWorld}`;
const proposedActionSection = `=== PROPOSED ACTION ===\nActor ID: ${intent.actorId}\nType: ${intent.type}\nContent: "${objectiveContent}"\nTarget IDs: ${intent.targetIds.join(", ") || "(None)"}`;
const userContext = `${worldStateSection}\n\n${proposedActionSection}\n\nDecide if the proposed action is logically valid and physically possible.`;
const components: PromptComponent[] = [
{ label: "System Prompt", type: "system", content: systemPrompt },
{
label: "Current World State",
type: "world",
content: worldStateSection,
},
{
label: "Proposed Action",
type: "input",
content: proposedActionSection,
},
];
return {
systemPrompt,
userContext,
components,
};
}
}

View File

@@ -1,7 +1,8 @@
import { z } from "zod";
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import { WorldState } from "@omnia/core";
import { ILLMProvider, PromptBreakdown } from "@omnia/llm";
import { Intent } from "@omnia/intent";
import { LLMValidatorPromptBuilder } from "./llm-validator-prompt-builder.js";
export const ValidationResultSchema = z.object({
isValid: z.boolean(),
@@ -11,12 +12,17 @@ export const ValidationResultSchema = z.object({
export type ValidationResult = z.infer<typeof ValidationResultSchema>;
export class LLMValidator {
constructor(private llmProvider: ILLMProvider) {}
public lastResult: PromptBreakdown | null = null;
private promptBuilder: LLMValidatorPromptBuilder;
constructor(private llmProvider: ILLMProvider) {
this.promptBuilder = new LLMValidatorPromptBuilder();
}
/**
* Validates an action intent against the objective world state.
*
* "monologue" intents must never reach this validator — they are internal
* "monologue" and "thought" intents must never reach this validator — they are internal
* thoughts that bypass validation entirely (see Architect.processIntent).
* This guard exists as a defensive safeguard.
*/
@@ -24,12 +30,14 @@ export class LLMValidator {
worldState: WorldState,
intent: Intent,
): Promise<ValidationResult> {
// Defensive guard: monologue intents bypass validation.
if (intent.type === "monologue") {
this.lastResult = null;
// Defensive guard: monologue and thought intents bypass validation.
if (intent.type === "monologue" || intent.type === "thought") {
return {
isValid: true,
reason:
"Monologue intents are internal thoughts and bypass validation.",
"Monologue/thought intents are internal thoughts and bypass validation.",
};
}
@@ -41,40 +49,16 @@ export class LLMValidator {
};
}
// 1. Serialize the objective world state for the LLM
const serializedWorld = serializeObjectiveWorldState(worldState);
const { systemPrompt, userContext, components } = this.promptBuilder.build(
worldState,
intent,
);
// 2. Build the prompts
const systemPrompt = `
You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game.
Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world.
Exempt dialogue or speech actions from validation (consider them always valid).
Enforce logical boundaries such as:
- Spatial boundaries (an actor cannot grab an object in another location unless they are there).
- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it).
- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so).
- State/Attribute constraints.
You must respond with a JSON object containing:
- "isValid": boolean indicating if the action is possible/allowed.
- "reason": a concise explanation of why the action is allowed or denied.
`.trim();
const userContext = `
=== CURRENT WORLD STATE ===
Current Time: ${worldState.clock.get().toISOString()}
Entities & Attributes:
${serializedWorld}
=== PROPOSED ACTION ===
Actor ID: ${intent.actorId}
Type: ${intent.type}
Description: "${intent.description}"
Original Text: "${intent.originalText}"
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
Decide if the proposed action is logically valid and physically possible.
`.trim();
this.lastResult = {
systemPrompt,
userContext,
components,
};
// structured call via the LLM provider
const response = await this.llmProvider.generateStructuredResponse({

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { describe, test, expect } from "vitest";
import {
WorldState,
Entity,
@@ -7,15 +7,16 @@ import {
AttributeVisibility,
} from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import { Intent } from "@omnia/intent";
import { Architect, AliasDeltaGenerator } from "../src/index.js";
describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
test("returns valid response when LLM validates intent as successful", async () => {
describe("World Architect Validation Tests (Tier 1)", () => {
test("returns valid response when LLM confirms the intent", async () => {
const world = new WorldState("world-1");
const alice = new Entity("alice");
world.addEntity(alice);
// Setup mock LLM response
const mockResponse = {
isValid: true,
reason: "Alice is in the room and the chest is unlocked.",
@@ -25,9 +26,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
const intent: Intent = {
type: "action",
originalText: "open the chest and read the scroll",
description: "Open the chest and read the scroll",
selfDescription: "You open the chest and read the scroll.",
content: "entity@alice[I] open the chest and read the scroll",
actorId: "alice",
targetIds: [],
modifiers: [],
@@ -56,9 +55,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
const intent: Intent = {
type: "action",
originalText: "unlock the gate and escape",
description: "Unlock the gate and escape",
selfDescription: "You unlock the gate and escape.",
content: "entity@bob[I] unlock the gate and escape",
actorId: "bob",
targetIds: [],
modifiers: [],
@@ -79,9 +76,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
const intent: Intent = {
type: "action",
originalText: "haunt the mansion",
description: "Haunt the mansion",
selfDescription: "You haunt the mansion.",
content: "entity@ghost[I] haunt the mansion",
actorId: "ghost",
targetIds: [],
modifiers: [],
@@ -128,9 +123,7 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
const intent: Intent = {
type: "action",
originalText: "pick the lock of the wooden chest",
description: "Pick the lock of the wooden chest",
selfDescription: "You pick the lock of the wooden chest.",
content: "entity@alice[I] pick the lock of the wooden chest",
actorId: "alice",
targetIds: [],
modifiers: [],
@@ -177,9 +170,7 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
const intent: Intent = {
type: "action",
originalText: "run away",
description: "Run away",
selfDescription: "You run away.",
content: "entity@bob[I] run away",
actorId: "bob",
targetIds: [],
modifiers: [],

View File

@@ -8,6 +8,7 @@
"references": [
{ "path": "../core" },
{ "path": "../llm" },
{ "path": "../intent" }
{ "path": "../intent" },
{ "path": "../voice" }
]
}

View File

@@ -9,6 +9,7 @@
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/voice": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -1,73 +1,34 @@
import { WorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
import { dehydrate, expandContractions } from "@omnia/voice";
import { Intent, IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
import { IntentDecoderPromptBuilder } from "./intent-prompt-builder.js";
export class IntentDecoder {
constructor(private llmProvider: ILLMProvider) {}
private promptBuilder: IntentDecoderPromptBuilder;
constructor(private llmProvider: ILLMProvider) {
this.promptBuilder = new IntentDecoderPromptBuilder();
}
/**
* Decodes narrative prose into an ordered sequence of structured intents.
*
* Responsibilities (from docs/intents.md):
* - Split prose into multiple intents when applicable.
* - Classify each intent as "dialogue", "action", or "monologue".
* - Parse narrative text into structured JSON with minimal information loss.
* - Contextually resolve receiving parties (targets).
*/
async decode(
worldState: WorldState,
actorId: string,
narrativeProse: string,
recentIntents: Intent[] = [],
): Promise<IntentSequence> {
const entityIds = Array.from(worldState.entities.keys());
const processedProse = expandContractions(narrativeProse);
const actor = worldState.getEntity(actorId);
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
const aliasContext =
aliasEntries.length > 0
? aliasEntries
.map(
([targetId, alias]) =>
`- "${alias}" refers to entity ID: "${targetId}"`,
)
.join("\n")
: "(No known aliases)";
const systemPrompt = `
You are the Intent Decoder for a narrative simulation engine.
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
For each intent you must:
1. Classify its type:
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
- "monologue": An inner thought, reflection, or internal monologue/self narration.
2. Extract the original text fragment from the prose that corresponds to this intent.
3. Populate "description" and "selfDescription":
- "description": No subject or name — a bare third-person verb phrase only (e.g. "clears their throat", "shakes their head slowly")
- "selfDescription": The same event from the actor's own perspective, second person, complete sentence starting with "You" (e.g. "You clear your throat.", "You shake your head slowly."). This is shown directly in the actor's own memory — it must never say "the actor" or refer to them in the third person.
- In case of a dialogue, the description and self Description only stores the exact words said by the entity. (e.g. "I will do that later", "Are you serious right now?")
4. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" mapping to resolve any subjective names,or aliases used in the prose to their correct system entity IDs. If no specific target, use an empty array.
5. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
`.trim();
const userContext = `
=== KNOWN ENTITY IDS ===
${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
=== ACTOR ALIASES ===
The actor refers to other entities using these subjective names/aliases:
${aliasContext}
=== WORLD STATE ===
${serializeSimplifiedWorldState(worldState)}
=== ACTOR ===
Actor ID: ${actorId}
=== NARRATIVE PROSE ===
${narrativeProse}
`.trim();
const { systemPrompt, userContext, components } = this.promptBuilder.build(
worldState,
actorId,
processedProse,
recentIntents,
);
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
@@ -81,42 +42,32 @@ ${narrativeProse}
);
}
const fullIntents = response.data.intents.map((intent) => ({
...intent,
actorId,
}));
const aliasMap: Record<string, string> = {};
if (actor) {
for (const [targetId, alias] of actor.aliases.entries()) {
aliasMap[alias] = targetId;
}
}
const fullIntents = response.data.intents.map((intent) => {
const dehydrated = dehydrate(
intent.content,
actorId,
intent.targetIds,
aliasMap,
);
return {
...intent,
content: dehydrated,
actorId,
};
});
return {
intents: fullIntents,
systemPrompt,
userContext,
promptComponents: components,
};
}
}
function serializeSimplifiedWorldState(worldState: WorldState): string {
const lines: string[] = [];
lines.push("Locations:");
if (worldState.locations.size > 0) {
for (const loc of worldState.locations.values()) {
const parentId = (loc as { parentId?: string | null }).parentId;
const parentStr = parentId ? ` (Parent: ${parentId})` : "";
lines.push(` - Location [ID: ${loc.id}]${parentStr}`);
}
} else {
lines.push(" (No locations)");
}
lines.push("Entities:");
if (worldState.entities.size > 0) {
for (const entity of worldState.entities.values()) {
const locStr = entity.locationId
? ` (Location: ${entity.locationId})`
: "";
lines.push(` - Entity [ID: ${entity.id}]${locStr}`);
}
} else {
lines.push(" (No entities)");
}
return lines.join("\n");
}

View File

@@ -0,0 +1,102 @@
import { WorldState, resolveAlias } from "@omnia/core";
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
import { Intent } from "./intent.js";
/**
* Prompt builder for the Intent Decoder.
* Separates prompt generation, structure, and component breakdowns.
*/
export class IntentDecoderPromptBuilder implements IPromptBuilder<
[WorldState, string, string, Intent[]]
> {
build(
worldState: WorldState,
actorId: string,
processedProse: string,
recentIntents: Intent[],
): PromptBreakdown {
const actor = worldState.getEntity(actorId);
// 1. Get other entities co-located in the same context
const otherEntitiesLines: string[] = [];
for (const otherEntity of worldState.entities.values()) {
if (
otherEntity.id !== actorId &&
otherEntity.locationId === actor?.locationId
) {
const alias = actor
? resolveAlias(actor, otherEntity.id)
: otherEntity.id;
otherEntitiesLines.push(` - Alias="${alias}" ID=${otherEntity.id}`);
}
}
const otherEntitiesContext =
otherEntitiesLines.length > 0
? otherEntitiesLines.join("\n")
: " (No other entities in context)";
// 2. Format historical context (2-3 recent intents received by the actor)
const historicalLines: string[] = [];
for (const prior of recentIntents) {
const targetIds =
prior.actorId !== actorId ? [prior.actorId] : prior.targetIds;
const targetsStr = targetIds
.map((tid) => {
const alias = actor ? resolveAlias(actor, tid) : tid;
return `(Alias="${alias}", ID="${tid}")`;
})
.join(", ");
historicalLines.push(
` - Content: "${prior.content}", Type: ${prior.type}, Target Entities: ${targetsStr || "None"}`,
);
}
const historicalContext =
historicalLines.length > 0
? historicalLines.join("\n")
: " (No prior intents in context)";
const systemPrompt = `
You are the Intent Decoder for a narrative simulation engine.
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
For each intent you must:
1. Classify its type:
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
- "action": Any physical action performed in the world (e.g., moving, opening, looking). DO NOT CLASSIFY SPEAKING MODIFIERS AS ACTIONS
- "monologue" (or "thought"): An inner thought, reflection, or monologue/self narration.
2. Extract the original narrative text fragment from the prose that corresponds to this intent and populate it as "content". Do not paraphrase, do not convert to third person, and do not convert to second person. Keep the original text fragment exactly as written in the prose (first-person voice).
3. Identify targetIds — the entity IDs of the receiving parties. Use the "Other entities in context" list to resolve any subjective names, aliases, or descriptions used in the prose to their correct entity IDs. If no specific target, use an empty array.
4. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
5. For dialogue intents always use the following format for content field:
I say "<dialogue>" (optionally: to him/her/alias).
`.trim();
const decoderContext = `
Intent Source: ${actorId}
Other entities in context:
${otherEntitiesContext}
Historical Context:
${historicalContext}
`.trim();
const narrativeProseSection = `=== NARRATIVE PROSE ===\n${processedProse}`;
const userContext = `${decoderContext}\n\n${narrativeProseSection}`;
const components: PromptComponent[] = [
{ label: "System Prompt", type: "system", content: systemPrompt },
{ label: "Decoder Context", type: "world", content: decoderContext },
{
label: "Narrative Prose",
type: "input",
content: narrativeProseSection,
},
];
return {
systemPrompt,
userContext,
components,
};
}
}

View File

@@ -6,9 +6,15 @@ import { z } from "zod";
* - "action": A physical or logical action performed in the world.
* - "monologue": An inner thought or internal monologue. Not perceivable by
* any other entity. Bypasses the Architect/validators entirely and is
* written directly to the actor's memory buffer with no outcome.
* written directly to the actor's Cognitive Buffer with no outcome.
* - "thought": Equivalent/alias to "monologue".
*/
export const IntentTypeSchema = z.enum(["dialogue", "action", "monologue"]);
export const IntentTypeSchema = z.enum([
"dialogue",
"action",
"monologue",
"thought",
]);
export type IntentType = z.infer<typeof IntentTypeSchema>;
/**
@@ -18,19 +24,13 @@ export const LLMIntentSchema = z.object({
/** The type of intent. */
type: IntentTypeSchema,
/** The original narrative text fragment this intent was extracted from. */
originalText: z.string(),
/** A concise, structured description of the intent's action or dialogue. */
description: z.string(),
/** The same event from the actor's own perspective (second person, "You"). */
selfDescription: z.string(),
/** The dehydrated canonical content of the intent. */
content: z.string(),
/**
* Entity IDs of the receiving parties (e.g., who is being spoken to,
* what object is being interacted with). Always an empty array for
* "monologue" intents, since they are not perceivable by anyone.
* "monologue" and "thought" intents, since they are not perceivable by anyone.
*/
targetIds: z.array(z.string()),
@@ -56,8 +56,14 @@ export const LLMIntentSequenceSchema = z.object({
* The full output of the Intent Decoder: an ordered sequence of intents
* extracted from a single narrative prose block.
*/
import { PromptComponent } from "@omnia/llm";
export const IntentSequenceSchema = z.object({
intents: z.array(IntentSchema),
});
export type IntentSequence = z.infer<typeof IntentSequenceSchema>;
export type IntentSequence = z.infer<typeof IntentSequenceSchema> & {
systemPrompt?: string;
userContext?: string;
promptComponents?: PromptComponent[];
};

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from "vitest";
import { WorldState, Entity } from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { IntentDecoder, IntentSequence } from "@omnia/intent";
import { IntentDecoder } from "@omnia/intent";
describe("IntentDecoder Unit Tests (Tier 1)", () => {
test("decodes prose with a single action intent", async () => {
@@ -9,13 +9,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
const alice = new Entity("alice");
world.addEntity(alice);
const mockResponse: IntentSequence = {
const mockResponse = {
intents: [
{
type: "action",
originalText: "Alice opened the chest.",
description: "Open the wooden chest.",
selfDescription: "You open the wooden chest.",
content: "I open the wooden chest.",
targetIds: [],
modifiers: [],
},
@@ -34,6 +32,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
expect(result.intents).toHaveLength(1);
expect(result.intents[0].type).toBe("action");
expect(result.intents[0].actorId).toBe("alice");
expect(result.intents[0].content).toContain("entity@alice[I]");
expect(result.intents[0].targetIds).toEqual([]);
});
@@ -44,13 +43,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
world.addEntity(alice);
world.addEntity(bob);
const mockResponse: IntentSequence = {
const mockResponse = {
intents: [
{
type: "dialogue",
originalText: '"Do you have the key?" Alice asked Bob.',
description: "Alice asks Bob if he has the key.",
selfDescription: "You ask Bob if he has the key.",
content: '"Do you have the key?" I asked Bob.',
targetIds: ["bob"],
modifiers: [],
},
@@ -68,6 +65,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
expect(result.intents).toHaveLength(1);
expect(result.intents[0].type).toBe("dialogue");
expect(result.intents[0].content).toContain("entity@alice[I]");
expect(result.intents[0].targetIds).toEqual(["bob"]);
});
@@ -78,21 +76,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
world.addEntity(alice);
world.addEntity(bob);
const mockResponse: IntentSequence = {
const mockResponse = {
intents: [
{
type: "dialogue",
originalText: '"Cover me," Alice whispered to Bob.',
description: "Alice whispers to Bob requesting cover.",
selfDescription: "You whisper to Bob requesting cover.",
content: '"Cover me," I whispered to Bob.',
targetIds: ["bob"],
modifiers: [],
},
{
type: "action",
originalText: "She crept towards the door and pulled the handle.",
description: "Creep towards the door and pull the handle.",
selfDescription: "You creep towards the door and pull the handle.",
content: "I crept towards the door and pulled the handle.",
targetIds: [],
modifiers: [],
},
@@ -113,6 +107,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
expect(result.intents[0].targetIds).toEqual(["bob"]);
expect(result.intents[1].type).toBe("action");
expect(result.intents[1].actorId).toBe("alice");
expect(result.intents[1].content).toContain("entity@alice[I]");
});
test("throws on LLM failure", async () => {

View File

@@ -5,5 +5,9 @@
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../core" }, { "path": "../llm" }]
"references": [
{ "path": "../core" },
{ "path": "../llm" },
{ "path": "../voice" }
]
}

532
packages/llm/README.md Normal file
View File

@@ -0,0 +1,532 @@
# @omnia/llm
LLM abstraction layer providing pluggable, database-backed provider instances for generative and embedding tasks.
## Architecture Overview
The system is built around four layers:
1. **Registry** — each provider class self-registers its metadata (id, envVar, capabilities, default model, etc.) via `static {}` blocks; `PROVIDER_REGISTRY` is derived from these registrations at runtime — there is no hand-maintained provider list
2. **Provider Manager** — SQLite-backed CRUD for persisted provider instances, with env-var bootstrap driven by the registry
3. **Provider Factory**`buildLLMProvider(inst)` / `buildEmbeddingProvider(inst)` resolve a stored instance to a live provider class via the registry
4. **Interfaces** — contracts that all providers implement
```mermaid
graph TD
subgraph Self-Registering Providers
GP["GeminiProvider"]
ORP["OpenRouterProvider"]
MP["MockLLMProvider"]
GEP["GeminiEmbeddingProvider"]
MEP["MockEmbeddingProvider"]
end
subgraph Registry
PR["ProviderRegistry\n(derived, not authored)"]
end
subgraph Storage
PM["ProviderManager\n(db.ts + bootstrap.ts + row-mapper.ts)"]
DB[("settings.db")]
end
subgraph Factory
PF["buildLLMProvider()\nbuildEmbeddingProvider()"]
end
GP -->|static block| PR
ORP -->|static block| PR
MP -->|static block| PR
GEP -->|static block| PR
MEP -->|static block| PR
PM -->|reads/writes| DB
PM -->|bootstrap from| PR
PF -->|looks up| PR
PF -->|instantiates| GP
PF -->|instantiates| ORP
```
## Core Interfaces
Defined in [`llm.ts`](src/llm.ts):
### `ILLMProvider`
The primary contract for generative (text-to-structured-data) providers.
| Member | Type | Description |
| ---------------------------------------- | ------------------------- | ---------------------------------------------------------- |
| `providerName` | `string` | Human-readable provider label |
| `maxContext` | `number?` | Maximum context window in tokens |
| `generateStructuredResponse<T>(request)` | `Promise<LLMResponse<T>>` | Sends a prompt + Zod schema → returns parsed, typed output |
| `lastCalls` | `LLMCallRecord[]?` | Audit trail of recent calls (prompts + usage) |
### `IEmbeddingProvider`
Contract for text-to-vector embedding providers.
| Member | Type | Description |
| -------------- | ------------------- | -------------------------------------------------- |
| `providerName` | `string` | Human-readable provider label |
| `embed(text)` | `Promise<number[]>` | Returns a dense vector embedding of the input text |
### `LLMRequest<T>`
Input to `generateStructuredResponse`:
```typescript
{
systemPrompt: string; // System-level instructions
userContext: string; // User/task-specific context
schema: T; // Zod schema — output is validated against this
temperature?: number; // Sampling temperature (optional)
}
```
### `LLMResponse<T>`
Output from `generateStructuredResponse`:
```typescript
{
success: boolean;
data?: T; // Parsed, schema-validated output
error?: string; // Error message on failure
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}
```
### `ModelProviderInstance`
The persisted configuration record for a single provider instance:
```typescript
{
id: string; // Unique ID ("provider-<timestamp>")
name: string; // User-facing name ("Gemini (Env)")
providerName: string; // Provider type key ("google-genai" | "openrouter" | "mock")
apiKey: string; // API key
isActive: boolean; // Whether this is the active instance for its type
modelName?: string; // Specific model to use
type: "generative" | "embedding"; // Instance category
maxContext?: number; // Context window limit
}
```
### `ModelProviderMeta`
Static metadata for each available provider type (used by the UI's provider picker):
| Member | Type | Description |
| ----------------------- | -------- | ------------------------------------------------------------ |
| `id` | `string` | `"google-genai"` \| `"openrouter"` \| `"ollama"` \| `"mock"` |
| `displayName` | `string` | Human-readable name |
| `description` | `string` | Human-readable description |
| `defaultModel` | `string` | Default generative model |
| `defaultEmbeddingModel` | `string` | Default embedding model |
Provider metadata is **self-declared** by each provider class in a `static {}` block and collected into `PROVIDER_REGISTRY` (derived, not authored). The `getAvailableProviders()` function and `AVAILABLE_PROVIDERS` helper in [`llm.ts`](src/llm.ts) read from the registry at call time.
## Provider Manager
`ProviderManager` is a **static class** that provides full CRUD over provider instances, backed by a SQLite database (`data/settings.db` at the workspace root). Internally split across:
- [`db.ts`](src/db.ts) — memoized DB handle + schema migrations (`PRAGMA user_version`)
- [`bin/setup-provider.ts`](src/bin/setup-provider.ts) — CLI tool to set up provider instances in the database
- [`row-mapper.ts`](src/row-mapper.ts) — `mapRow()` (written once, used everywhere)
- [`provider-manager.ts`](src/provider-manager.ts) — thin CRUD: `list`, `create`, `delete`, `setActive`, `update`, `getActive`, `getMappings`, `setMapping`
### Storage
The database is auto-created on first access. The table schema:
```sql
CREATE TABLE IF NOT EXISTS provider_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
providerName TEXT NOT NULL,
apiKey TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 0,
modelName TEXT,
type TEXT NOT NULL DEFAULT 'generative',
maxContext INTEGER
);
```
A second table stores per-task provider overrides:
```sql
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
);
```
### API
| Method | Signature | Description |
| ------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
| `list()` | `→ ModelProviderInstance[]` | Returns all saved instances |
| `create(name, providerName, apiKey, modelName?, type?, maxContext?)` | `→ ModelProviderInstance` | Creates a new instance. Auto-activates if it's the first of its type |
| `delete(id)` | `→ void` | Removes an instance. If it was active, auto-promotes the next instance of the same type |
| `setActive(id)` | `→ void` | Deactivates all instances of the same type, then activates the target |
| `update(id, name, providerName, apiKey?, modelName?, type?, maxContext?)` | `→ void` | Updates an existing instance. If `apiKey` is empty/omitted, the existing key is preserved |
| `getActive(type?)` | `→ ModelProviderInstance \| null` | Returns the currently active instance for the given type (`"generative"` by default) |
| `getMappings()` | `→ Record<string, string>` | Returns all task → providerInstanceId mappings |
| `setMapping(task, providerInstanceId)` | `→ void` | Sets or removes (if `providerInstanceId` is empty) a task-specific mapping |
### Active Instance Invariants
- **Only one active instance per type** — `setActive()` deactivates all sibling instances before activating the target.
- **Auto-promotion on delete** — if the deleted instance was active, the first remaining instance of the same type is promoted.
- **Auto-activation on create** — if no active instance exists for the type, the new instance is automatically activated.
### Manual Seeding via CLI
Rather than automatically bootstrapping from environment variables at runtime, which adds runtime complexity, you can quickly seed the database using the CLI setup tool:
#### Seeding All Environment-Variable Providers
```bash
pnpm setup-provider --all
```
This command auto-detects and inserts provider instances into `data/settings.db` for any registered providers whose corresponding environment variables (such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, etc.) are defined.
#### Creating a Specific Provider Instance
```bash
pnpm setup-provider --provider google-genai --key YOUR_API_KEY [--name "My Gemini"] [--model gemini-2.5-flash] [--type generative] [--max-context 32768] [--endpoint url]
```
### Credential Resolution Cascade
When initializing a provider (e.g. `new GeminiProvider()`):
1. **Explicit Credentials** — If `apiKey`, `modelName`, etc. are passed directly to the constructor, they are used.
2. **Active DB Instance** — If not explicitly passed, it looks up the active DB instance via `ProviderManager.getActive()`. If found and matches the provider, its API key and configuration are used.
3. **Environment Fallback** — If there is no matching active DB instance, it resolves the key directly from the corresponding environment variable (e.g., `GOOGLE_API_KEY`) via `resolveCredentials`.
## Available Providers
### Google Gemini — `GeminiProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------------ |
| **File** | [`providers/google-genai.ts`](src/providers/google-genai.ts) |
| **Provider ID** | `google-genai` |
| **SDK** | `@langchain/google-genai` (`ChatGoogleGenerativeAI`) |
| **Default Model** | `gemini-2.5-flash` |
| **Default Embedding Model** | `gemini-embedding-001` |
| **Default Max Context** | `32768` |
| **Type** | Generative |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "google-genai"
3. GOOGLE_API_KEY env var → final fallback
4. None found → throw Error
```
Also exports `GeminiEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern but querying for the `"embedding"` type.
### Anthropic Claude — `AnthropicProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------ |
| **File** | [`providers/anthropic.ts`](src/providers/anthropic.ts) |
| **Provider ID** | `anthropic` |
| **SDK** | `@langchain/anthropic` (`ChatAnthropic`) |
| **Default Model** | `claude-3-5-sonnet-latest` |
| **Default Embedding Model** | _(none)_ |
| **Default Max Context** | `200000` |
| **Type** | Generative only (no embedding provider) |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "anthropic"
3. ANTHROPIC_API_KEY env var → final fallback
4. None found → throw Error
```
### Groq — `GroqProvider`
| Property | Value |
| --------------------------- | -------------------------------------------- |
| **File** | [`providers/groq.ts`](src/providers/groq.ts) |
| **Provider ID** | `groq` |
| **SDK** | `@langchain/groq` (`ChatGroq`) |
| **Default Model** | `llama-3.3-70b-versatile` |
| **Default Embedding Model** | _(none)_ |
| **Default Max Context** | `8192` |
| **Type** | Generative only (no embedding provider) |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "groq"
3. GROQ_API_KEY env var → final fallback
4. None found → throw Error
```
### DeepSeek — `DeepSeekProvider`
| Property | Value |
| --------------------------- | ---------------------------------------------------- |
| **File** | [`providers/deepseek.ts`](src/providers/deepseek.ts) |
| **Provider ID** | `deepseek` |
| **SDK** | `@langchain/deepseek` (`ChatDeepSeek`) |
| **Default Model** | `deepseek-chat` |
| **Default Embedding Model** | _(none)_ |
| **Default Max Context** | `64000` |
| **Type** | Generative only (no embedding provider) |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "deepseek"
3. DEEPSEEK_API_KEY env var → final fallback
4. None found → throw Error
```
### OpenAI — `OpenAIProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------ |
| **File** | [`providers/openai.ts`](src/providers/openai.ts) |
| **Provider ID** | `openai` |
| **SDK** | `@langchain/openai` (`ChatOpenAI`, `OpenAIEmbeddings`) |
| **Default Model** | `gpt-4o-mini` |
| **Default Embedding Model** | `text-embedding-3-small` |
| **Default Max Context** | `128000` |
| **Type** | Generative + Embedding |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "openai"
3. OPENAI_API_KEY env var → final fallback
4. None found → throw Error
```
Also exports `OpenAIEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern against the `"embedding"` type instance. The default embedding model is `text-embedding-3-small`.
### OpenRouter — `OpenRouterProvider`
| Property | Value |
| --------------------------- | -------------------------------------------------------- |
| **File** | [`providers/openrouter.ts`](src/providers/openrouter.ts) |
| **Provider ID** | `openrouter` |
| **SDK** | `@langchain/openrouter` (`ChatOpenRouter`) |
| **Default Model** | `google/gemini-2.5-flash` |
| **Default Embedding Model** | `openai/text-embedding-3-small` |
| **Default Max Context** | `32768` |
| **Type** | Generative only (no embedding provider) |
Same three-step key resolution as Gemini (`explicit → ProviderManager → env var`), using `OPENROUTER_API_KEY`.
### Ollama — `OllamaProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------ |
| **File** | [`providers/ollama.ts`](src/providers/ollama.ts) |
| **Provider ID** | `ollama` |
| **SDK** | `@langchain/ollama` (`ChatOllama`) |
| **Default Model** | `llama3.1` |
| **Default Embedding Model** | `nomic-embed-text` |
| **Default Max Context** | `32768` |
| **Type** | Generative + Embedding |
Ollama runs **locally** — no API key is required. The `endpointUrl` field in `ModelProviderInstance` stores the Ollama server base URL (default: `http://localhost:11434`).
**Key resolution** in the constructor:
```
1. Explicit baseUrl argument → use it
2. ProviderManager.getActive() → if providerName matches "ollama"
(endpointUrl field = base URL)
3. Default → http://localhost:11434
```
Also exports `OllamaEmbeddingProvider` (implements `IEmbeddingProvider`), which uses the same resolution pattern against the `"embedding"` type instance. The default embedding model is `nomic-embed-text`.
> [!TIP]
> To get started: `ollama pull llama3.1` and `ollama pull nomic-embed-text`. Then create a provider instance with `endpointUrl` = `http://localhost:11434`.
### Mock — `MockLLMProvider`
| Property | Value |
| --------------- | -------------------------------------------- |
| **File** | [`providers/mock.ts`](src/providers/mock.ts) |
| **Provider ID** | `mock` |
| **Type** | Generative + Embedding |
Stateless mock for testing and offline development:
- **Generative** (`MockLLMProvider`): Takes an array of canned responses at construction. Returns them in order, one per call. Returns `{ success: false, error: "Mock responses exhausted" }` when depleted.
- **Embedding** (`MockEmbeddingProvider`): Returns a deterministic 768-dimensional vector derived from the input text using `Math.sin`.
## Provider Resolution (Runtime)
The [`resolveProviders()`](../../../apps/gui/src/lib/simulation/provider-resolver.ts) function (in `apps/gui`) instantiates all providers needed for a simulation session. It resolves **six** provider slots:
| Slot | Type | Task Key |
| ------------------- | ---------- | ------------------ |
| `actorProvider` | Generative | `"actor-prose"` |
| `validatorProvider` | Generative | `"llm-validator"` |
| `decoderProvider` | Generative | `"intent-decoder"` |
| `timedeltaProvider` | Generative | `"timedelta"` |
| `handoffProvider` | Generative | `"handoff"` |
| `embeddingProvider` | Embedding | `"embeddings"` |
### Generative Resolution Order
For each generative slot (`resolveGenerative(task)`):
```
1. Task-specific mapping → mappings[task] → find instance by ID
2. Active generative instance → ProviderManager.getActive("generative")
3. Fallback instance → options.fallbackInstance (if provided)
4. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
5. No provider available → throw Error (if required) or MockLLMProvider
```
### Embedding Resolution Order
For the embedding slot (`resolveEmbedding()`):
```
1. Task-specific mapping → mappings["embeddings"] → find instance by ID
2. Active embedding instance → ProviderManager.getActive("embedding")
3. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
4. No provider available → throw Error (if required) or MockEmbeddingProvider
```
### Instance → Class Mapping
The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the final dispatch:
| `providerName` | Generative Class | Embedding Class |
| ----------------- | -------------------- | ------------------------- |
| `"google-genai"` | `GeminiProvider` | `GeminiEmbeddingProvider` |
| `"openai"` | `OpenAIProvider` | `OpenAIEmbeddingProvider` |
| `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ |
| `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` |
| `"anthropic"` | `AnthropicProvider` | _(falls through to mock)_ |
| `"groq"` | `GroqProvider` | _(falls through to mock)_ |
| `"deepseek"` | `DeepSeekProvider` | _(falls through to mock)_ |
| _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` |
## Model Listing and Discovery
The `ModelLister` class provides a unified interface to dynamically query available models from the remote provider APIs.
### Caching and TTL
All list requests are cached in-memory with a **5-minute TTL** (`300,000ms`) to prevent rapid, repetitive remote API requests and avoid rate limit exhaustion.
- **Cache Key**: Generated using `providerName` combined with either the `apiKey` or `endpointUrl`.
- **Invalidation**: Call `ModelLister.invalidateCache(providerName, apiKey, endpointUrl)` to clear cache for specific instances, or `ModelLister.clearCache()` to wipe all lists.
### Provider Integration Details
| Provider | Endpoint | Auth Header | Pagination |
| ----------------- | ---------------------------- | ----------------------- | --------------------------- |
| **Google Gemini** | `GET /v1beta/models?key=KEY` | Query Param | ✅ Loop via `nextPageToken` |
| **OpenAI** | `GET /v1/models` | `Authorization: Bearer` | ❌ |
| **Anthropic** | `GET /v1/models` | `x-api-key` | ✅ Loop via `after_id` |
| **Groq** | `GET /openai/v1/models` | `Authorization: Bearer` | ❌ |
| **DeepSeek** | `GET /models` | `Authorization: Bearer` | ❌ |
| **Ollama** | `GET /api/tags` | None (Local) | ❌ |
| **OpenRouter** | `GET /api/v1/models` | Optional Bearer | ❌ |
| **Mock** | Instant return (no fetch) | — | — |
### Methods
- `listModels(providerName: string, apiKey: string, endpointUrl?: string): Promise<ModelInfo[]>`
- `invalidateCache(providerName: string, apiKey: string, endpointUrl?: string): void`
- `clearCache(): void`
---
## Structured Output
All real providers use LangChain's `.withStructuredOutput(schema, { includeRaw: true })` pattern:
```typescript
const structuredModel = this.model.withStructuredOutput(request.schema, {
includeRaw: true,
});
const result = await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
]);
```
This sends the Zod schema to the model as a structured output constraint. The response includes both `parsed` (schema-validated data) and `raw` (full API response with usage metadata).
## Configuration
[`config.ts`](src/config.ts) parses environment variables using Zod:
| Variable | Required | Description |
| -------------------- | -------- | ------------------------ |
| `GOOGLE_API_KEY` | No | Google Gemini API key |
| `OPENAI_API_KEY` | No | OpenAI API key |
| `OPENROUTER_API_KEY` | No | OpenRouter API key |
| `ANTHROPIC_API_KEY` | No | Anthropic Claude API key |
| `GROQ_API_KEY` | No | Groq API key |
| `DEEPSEEK_API_KEY` | No | DeepSeek API key |
Env var keys are derived from `PROVIDER_REGISTRY` — each provider's `envVar` field is read by `getLlmConfig()` to build the zod schema lazily. Adding a new provider with `envVar: "NEW_KEY"` automatically adds it to config validation.
## File Map
```
packages/llm/
├── src/
│ ├── index.ts # Re-exports everything
│ ├── llm.ts # Interfaces, types, getAvailableProviders()
│ ├── registry.ts # ProviderRegistry (derived), registerProvider/registerGenerative/registerEmbedding
│ ├── base-provider.ts # BaseLLMProvider (shared generateStructuredResponse), resolveCredentials
│ ├── config.ts # Env var parsing (lazy, registry-derived Zod)
│ ├── model-lister.ts # ModelLister (cache + fetchWithTimeout), fetchOpenAICompatibleModels
│ ├── provider-factory.ts # buildLLMProvider() / buildEmbeddingProvider() (registry lookup)
│ ├── provider-manager.ts # ProviderManager (thin CRUD)
│ ├── db.ts # Memoized DB handle + migrations
│ ├── row-mapper.ts # mapRow()
│ ├── bin/
│ │ └── setup-provider.ts # CLI tool to set up provider instances in the database
│ └── providers/
│ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider (self-registering)
│ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider
│ ├── openrouter.ts # OpenRouterProvider
│ ├── anthropic.ts # AnthropicProvider
│ ├── openai.ts # OpenAIProvider + OpenAIEmbeddingProvider
│ ├── groq.ts # GroqProvider
│ ├── deepseek.ts # DeepSeekProvider
│ └── mock.ts # MockLLMProvider + MockEmbeddingProvider
├── tests/
│ ├── mock.test.ts
│ ├── openrouter.test.ts
│ ├── model-lister.test.ts # ModelLister cache and fetch logic unit tests
│ ├── provider-manager.test.ts
│ └── cli.test.ts # Integration tests for setup-provider CLI tool
└── package.json
```

View File

@@ -6,6 +6,9 @@
"exports": {
".": "./dist/index.js"
},
"scripts": {
"setup-provider": "node ./dist/bin/setup-provider.js"
},
"dependencies": {
"@types/node": "^26.1.0",
"better-sqlite3": "^12.11.1"

View File

@@ -0,0 +1,113 @@
import { z } from "zod";
import type {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
} from "./llm.js";
import { ProviderManager } from "./provider-manager.js";
import { getLlmConfig } from "./config.js";
export interface ResolvedCredentials {
key: string | undefined;
model: string | undefined;
providerInstanceName: string | undefined;
maxContext: number | undefined;
}
export function resolveCredentials(opts: {
explicitKey?: string;
explicitModel?: string;
explicitProviderInstanceName?: string;
explicitMaxContext?: number;
providerId: string;
envVarName: string;
type: "generative" | "embedding";
}): ResolvedCredentials {
let key = opts.explicitKey;
let model = opts.explicitModel;
let providerInstanceName = opts.explicitProviderInstanceName;
let maxContext = opts.explicitMaxContext;
if (!key) {
const active = ProviderManager.getActive(opts.type);
if (active && active.providerName === opts.providerId) {
key = active.apiKey;
if (!model) model = active.modelName;
if (!providerInstanceName) providerInstanceName = active.name;
if (maxContext === undefined) maxContext = active.maxContext;
}
}
if (!key) {
const cfg = getLlmConfig();
key = cfg[opts.envVarName];
if (!providerInstanceName && key) {
providerInstanceName = "Environment Variable";
}
}
return { key, model, providerInstanceName, maxContext };
}
export abstract class BaseLLMProvider implements ILLMProvider {
abstract providerName: string;
protected abstract readonly model: unknown;
protected abstract modelNameUsed: string;
protected abstract providerInstanceName?: string;
protected abstract maxContextUsed?: number;
protected abstract defaultMaxContext: number;
lastCalls: LLMCallRecord[] = [];
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = (
this.model as {
withStructuredOutput(
s: z.ZodTypeAny,
o: { includeRaw: true },
): {
invoke(m: unknown): Promise<unknown>;
};
}
).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 = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext:
this.maxContextUsed !== undefined
? this.maxContextUsed
: this.defaultMaxContext,
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
response: parsed,
});
return { success: true, data: parsed, usage };
}
}

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
import { ProviderRegistry, ProviderManager } from "../index.js";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// Load dotenv from workspace root
function loadEnv() {
let current = process.cwd();
while (current !== "/" && current !== path.parse(current).root) {
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) {
dotenv.config({ path: path.join(current, ".env") });
return;
}
current = path.dirname(current);
}
dotenv.config();
}
loadEnv();
function printHelp() {
console.log(`
Usage:
node packages/llm/dist/bin/setup-provider.js [options]
Options:
--provider <id> ID of the provider (e.g. google-genai, openai, anthropic, groq, etc.)
--name <name> Display name for the instance (default: provider displayName)
--key <key> API key (default: loaded from the provider's env variable, e.g. GOOGLE_API_KEY)
--model <model> Model name (default: provider's default model)
--type <type> "generative" | "embedding" (default: "generative")
--max-context <num> Max context window tokens (default: provider's default max context)
--endpoint <url> Custom endpoint URL (optional)
--all Auto-detect and seed all providers whose environment variables are set
-h, --help Show this help message
Registered Providers:
${ProviderRegistry.all()
.map((p) => ` - ${p.id} (${p.displayName}) [Env: ${p.envVar || "None"}]`)
.join("\n")}
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("-h") || args.includes("--help") || args.length === 0) {
printHelp();
process.exit(0);
}
const options: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const nextVal = args[i + 1];
if (nextVal && !nextVal.startsWith("--")) {
options[key] = nextVal;
i++;
} else {
options[key] = "true";
}
}
}
if (options.all === "true") {
// Seed all providers from environment variables
const existing = ProviderManager.list();
let seededCount = 0;
for (const def of ProviderRegistry.all()) {
if (!def.envVar) continue;
const key = process.env[def.envVar]?.trim();
if (!key) continue;
if (def.capabilities.generative) {
const hasGen = existing.some(
(p) => p.providerName === def.id && p.type === "generative",
);
if (!hasGen) {
const name = `${def.displayName} (CLI)`;
ProviderManager.create(
name,
def.id,
key,
def.defaultModel,
"generative",
def.defaultMaxContext,
);
console.log(`Created generative instance: ${name}`);
seededCount++;
}
}
if (def.capabilities.embedding) {
const hasEmbed = existing.some(
(p) => p.providerName === def.id && p.type === "embedding",
);
if (!hasEmbed) {
const name = `${def.displayName} Embed (CLI)`;
ProviderManager.create(
name,
def.id,
key,
def.defaultEmbeddingModel || "",
"embedding",
0,
);
console.log(`Created embedding instance: ${name}`);
seededCount++;
}
}
}
if (seededCount === 0) {
console.log(
"No new provider instances seeded. (Either already existed or env vars not set)",
);
} else {
console.log(`Successfully seeded ${seededCount} provider instance(s).`);
}
process.exit(0);
}
const providerId = options.provider;
if (!providerId) {
console.error("Error: --provider <id> or --all is required.");
printHelp();
process.exit(1);
}
const def = ProviderRegistry.get(providerId);
if (!def) {
console.error(`Error: Provider '${providerId}' is not registered.`);
console.error(
`Available providers: ${ProviderRegistry.all()
.map((p) => p.id)
.join(", ")}`,
);
process.exit(1);
}
const type = (options.type === "embedding" ? "embedding" : "generative") as
"generative" | "embedding";
// Resolve key
let apiKey: string | undefined = options.key;
if (!apiKey && def.envVar) {
apiKey = process.env[def.envVar]?.trim();
}
if (!apiKey) {
console.error(
`Error: API Key is required. Please set ${def.envVar || "the environment variable"} or pass --key <apiKey>.`,
);
process.exit(1);
}
// Resolve model
const defaultModel =
type === "embedding" ? def.defaultEmbeddingModel || "" : def.defaultModel;
const modelName = options.model || defaultModel;
// Resolve name
const name = options.name || `${def.displayName} (CLI)`;
// Resolve maxContext
const maxContext = options["max-context"]
? parseInt(options["max-context"], 10)
: type === "embedding"
? 0
: def.defaultMaxContext;
const endpointUrl = options.endpoint;
const instance = ProviderManager.create(
name,
def.id,
apiKey,
modelName,
type,
maxContext,
endpointUrl,
);
console.log(`Successfully created provider instance:`);
console.log(JSON.stringify(instance, null, 2));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,8 +1,25 @@
import { z } from "zod";
import { ProviderRegistry } from "./registry.js";
const LLMConfigSchema = z.object({
GOOGLE_API_KEY: z.string().optional(),
OPENROUTER_API_KEY: z.string().optional(),
});
let _config: Record<string, string | undefined> | null = null;
export const llmConfig = LLMConfigSchema.parse(process.env);
export function getLlmConfig(): Record<string, string | undefined> {
if (!_config) {
const envVars: string[] = [];
for (const def of ProviderRegistry.all()) {
if (def.envVar && !envVars.includes(def.envVar)) {
envVars.push(def.envVar);
}
}
const shape: Record<string, z.ZodOptional<z.ZodString>> = {};
for (const key of envVars) {
shape[key] = z.string().optional();
}
_config = z.object(shape).parse(process.env);
}
return _config;
}
export function resetLlmConfig(): void {
_config = null;
}

82
packages/llm/src/db.ts Normal file
View File

@@ -0,0 +1,82 @@
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import path from "path";
import fs from "fs";
let _db: BetterSqlite3.Database | null = null;
let _dbPathOverride: string | null = null;
export function setDbPath(p: string | null) {
if (_dbPathOverride !== p) {
_db?.close();
_db = null;
_dbPathOverride = p;
}
}
function findDbPath(): string {
if (process.env.OMNIA_DB_PATH) {
const dir = path.dirname(process.env.OMNIA_DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return process.env.OMNIA_DB_PATH;
}
let current = process.cwd();
while (current !== "/" && current !== path.parse(current).root) {
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) {
const dbDir = path.resolve(current, "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
return path.join(dbDir, "settings.db");
}
current = path.dirname(current);
}
const dbDir = path.resolve(process.cwd(), "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
return path.join(dbDir, "settings.db");
}
function runMigrations(db: BetterSqlite3.Database): void {
const version = db.pragma("user_version", { simple: true }) as number;
if (version < 1) {
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
providerName TEXT NOT NULL,
apiKey TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 0,
modelName TEXT,
type TEXT NOT NULL DEFAULT 'generative',
maxContext INTEGER,
endpointUrl TEXT
)
`,
).run();
db.pragma("user_version = 1");
}
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`,
).run();
}
export function getDb(): BetterSqlite3.Database {
if (!_db) {
const dbPath = _dbPathOverride ?? findDbPath();
_db = new Database(dbPath);
runMigrations(_db);
}
return _db;
}

View File

@@ -1,6 +1,14 @@
export * from "./llm.js";
export * from "./config.js";
export * from "./registry.js";
export * from "./provider-factory.js";
export * from "./model-lister.js";
export * from "./provider-manager.js";
export * from "./providers/google-genai.js";
export * from "./providers/mock.js";
export * from "./providers/ollama.js";
export * from "./providers/openrouter.js";
export * from "./provider-manager.js";
export * from "./providers/anthropic.js";
export * from "./providers/openai.js";
export * from "./providers/groq.js";
export * from "./providers/deepseek.js";

View File

@@ -1,4 +1,21 @@
import { z } from "zod";
import { ProviderRegistry } from "./registry.js";
export interface PromptComponent {
label: string;
type: "system" | "world" | "events" | "memories" | "input" | "other";
content: string;
}
export interface PromptBreakdown {
systemPrompt: string;
userContext: string;
components?: PromptComponent[];
}
export interface IPromptBuilder<TArgs extends unknown[]> {
build(...args: TArgs): PromptBreakdown;
}
export interface LLMRequest<T extends z.ZodTypeAny> {
systemPrompt: string;
@@ -32,6 +49,7 @@ export interface LLMCallRecord {
providerInstanceName?: string;
maxContext?: number;
};
response?: unknown;
}
export interface ILLMProvider {
@@ -57,6 +75,7 @@ export interface ModelProviderInstance {
modelName?: string;
type: "generative" | "embedding";
maxContext?: number;
endpointUrl?: string;
}
export interface ModelProviderMeta {
@@ -67,27 +86,21 @@ export interface ModelProviderMeta {
defaultEmbeddingModel: string;
}
export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
{
id: "google-genai",
displayName: "Google Gemini",
description: "Official Gemini integration using Google Gen AI SDK",
defaultModel: "gemini-2.5-flash",
defaultEmbeddingModel: "gemini-embedding-001",
export function getAvailableProviders(): ModelProviderMeta[] {
return ProviderRegistry.all().map((def) => ({
id: def.id,
displayName: def.displayName,
description: def.description,
defaultModel: def.defaultModel,
defaultEmbeddingModel: def.defaultEmbeddingModel || "",
}));
}
export const AVAILABLE_PROVIDERS = {
get count(): number {
return getAvailableProviders().length;
},
{
id: "openrouter",
displayName: "OpenRouter",
description:
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
defaultModel: "google/gemini-2.5-flash",
defaultEmbeddingModel: "openai/text-embedding-3-small",
toArray(): ModelProviderMeta[] {
return getAvailableProviders();
},
{
id: "mock",
displayName: "Mock LLM Provider",
description: "Stateless mock provider for testing and offline development",
defaultModel: "mock",
defaultEmbeddingModel: "mock-embeddings",
},
];
};

View File

@@ -0,0 +1,105 @@
/**
* ModelLister — fetches available models from each provider's REST API.
* Results are cached in-memory with a 5-minute TTL to avoid repeated calls.
*/
import { ProviderRegistry } from "./registry.js";
export interface ModelInfo {
id: string;
name: string;
ownedBy?: string;
}
interface CacheEntry {
models: ModelInfo[];
fetchedAt: number;
}
const CACHE_TTL_MS = 5 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10_000;
const modelCache = new Map<string, CacheEntry>();
function cacheKey(
providerName: string,
apiKey: string,
endpointUrl?: string,
): string {
return `${providerName}:${endpointUrl || apiKey}`;
}
export async function fetchWithTimeout(
url: string,
init?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function fetchOpenAICompatibleModels(
baseUrl: string,
apiKey: string,
): Promise<ModelInfo[]> {
const res = await fetchWithTimeout(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (!res.ok) return [];
const json = (await res.json()) as {
data?: { id: string; owned_by?: string; name?: string }[];
};
return (json.data ?? []).map((m) => ({
id: m.id,
name: m.name || m.id,
ownedBy: m.owned_by,
}));
}
export class ModelLister {
static async listModels(
providerName: string,
apiKey: string,
endpointUrl?: string,
): Promise<ModelInfo[]> {
const key = cacheKey(providerName, apiKey, endpointUrl);
const cached = modelCache.get(key);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.models;
}
const def = ProviderRegistry.get(providerName);
let models: ModelInfo[] = [];
try {
if (def?.listModels) {
models = await def.listModels(apiKey, endpointUrl);
}
} catch {
models = [];
}
modelCache.set(key, { models, fetchedAt: Date.now() });
return models;
}
static invalidateCache(
providerName: string,
apiKey: string,
endpointUrl?: string,
): void {
modelCache.delete(cacheKey(providerName, apiKey, endpointUrl));
}
static clearCache(): void {
modelCache.clear();
}
}

View File

@@ -0,0 +1,21 @@
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
} from "./llm.js";
import { MockLLMProvider, MockEmbeddingProvider } from "./providers/mock.js";
import { ProviderRegistry } from "./registry.js";
export function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider {
const def = ProviderRegistry.get(inst.providerName);
return def?.generativeCreate?.(inst) ?? new MockLLMProvider([]);
}
export function buildEmbeddingProvider(
inst: ModelProviderInstance,
): IEmbeddingProvider {
const def = ProviderRegistry.get(inst.providerName);
return (
def?.embeddingCreate?.(inst) ?? new MockEmbeddingProvider(inst.modelName)
);
}

View File

@@ -1,196 +1,16 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { ModelProviderInstance } from "./llm.js";
import { getDb } from "./db.js";
import { mapRow, type DbRow } from "./row-mapper.js";
let dbPathOverride: string | null = null;
let hasBootstrapped = false;
export function setDbPathOverride(p: string | null) {
dbPathOverride = p;
}
export function resetHasBootstrapped() {
hasBootstrapped = false;
}
function getWorkspaceRoot() {
let current = process.cwd();
while (current !== "/" && current !== path.parse(current).root) {
if (
fs.existsSync(path.join(current, "pnpm-workspace.yaml")) ||
fs.existsSync(path.join(current, "package.json"))
) {
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) {
return current;
}
}
current = path.dirname(current);
}
return process.cwd();
}
function getSettingsDb() {
let dbPath: string;
if (dbPathOverride) {
dbPath = dbPathOverride;
} else {
const wsRoot = getWorkspaceRoot();
const dbDir = path.resolve(wsRoot, "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
dbPath = path.join(dbDir, "settings.db");
}
const db = new Database(dbPath);
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
providerName TEXT NOT NULL,
apiKey TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 0,
modelName TEXT,
type TEXT NOT NULL DEFAULT 'generative'
)
`,
).run();
try {
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`,
).run();
} catch {
// ignore
}
try {
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`,
).run();
} catch {
// ignore
}
try {
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`,
).run();
} catch {
// ignore
}
// Auto-bootstrap environment variables if DB contains 0 instances
try {
if (!hasBootstrapped) {
const totalCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
.get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"Gemini (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-2.5-flash",
"generative",
32768,
);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
embedId,
"Gemini Embed (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-embedding-001",
"embedding",
0,
);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"OpenRouter (Env)",
"openrouter",
openRouterKey.trim(),
isActive,
"google/gemini-2.5-flash",
"generative",
32768,
);
}
}
hasBootstrapped = true;
}
} catch {
// ignore write lock issues or other DB errors during bootstrap
}
return db;
}
export { setDbPath as setDbPathOverride } from "./db.js";
export class ProviderManager {
static list(): ModelProviderInstance[] {
const db = getSettingsDb();
try {
const rows = db.prepare(`SELECT * FROM provider_instances`).all() as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}[];
return rows.map((r) => ({
id: r.id,
name: r.name,
providerName: r.providerName,
apiKey: r.apiKey,
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
type: (r.type as "generative" | "embedding") || "generative",
maxContext:
r.maxContext !== undefined && r.maxContext !== null
? r.maxContext
: r.type === "embedding"
? 0
: 32768,
}));
} finally {
db.close();
}
const db = getDb();
const rows = db
.prepare("SELECT * FROM provider_instances")
.all() as DbRow[];
return rows.map(mapRow);
}
static create(
@@ -200,94 +20,123 @@ export class ProviderManager {
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): ModelProviderInstance {
const db = getSettingsDb();
try {
const id = "provider-" + Date.now();
const activeCount = db
.prepare(
`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`,
)
.get(type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
const db = getDb();
const id = "provider-" + Date.now();
const activeCount = db
.prepare(
"SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?",
)
.get(type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
const actualMaxContext =
maxContext !== undefined
? maxContext
: type === "generative"
? 32768
: 0;
const actualMaxContext =
maxContext !== undefined ? maxContext : type === "generative" ? 32768 : 0;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
name,
providerName,
apiKey,
isActive,
modelName || null,
type,
actualMaxContext,
);
db.prepare(
`INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
name,
providerName,
apiKey,
isActive,
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
);
return {
id,
name,
providerName,
apiKey,
isActive: isActive === 1,
modelName,
type,
maxContext: actualMaxContext,
};
} finally {
db.close();
}
return {
id,
name,
providerName,
apiKey,
isActive: isActive === 1,
modelName,
type,
maxContext: actualMaxContext,
endpointUrl,
};
}
static delete(id: string): void {
const db = getSettingsDb();
try {
const provider = db
.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`)
.get(id) as { isActive: number; type: string } | undefined;
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
static duplicate(id: string): ModelProviderInstance | null {
const db = getDb();
const source = db
.prepare("SELECT * FROM provider_instances WHERE id = ?")
.get(id) as DbRow | undefined;
if (!source) return null;
if (provider && provider.isActive === 1) {
const next = db
.prepare(`SELECT id FROM provider_instances WHERE type = ? LIMIT 1`)
.get(provider.type) as { id: string } | undefined;
if (next) {
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(next.id);
}
const newId = "provider-" + Date.now();
const newName = `${source.name} (Copy)`;
const activeCount = db
.prepare(
"SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?",
)
.get(source.type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
db.prepare(
`INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
newId,
newName,
source.providerName,
source.apiKey,
isActive,
source.modelName,
source.type,
source.maxContext,
source.endpointUrl,
);
return {
id: newId,
name: newName,
providerName: source.providerName,
apiKey: source.apiKey,
isActive: isActive === 1,
modelName: source.modelName || undefined,
type: source.type as "generative" | "embedding",
maxContext: source.maxContext,
endpointUrl: source.endpointUrl || undefined,
};
}
static delete(id: string): void {
const db = getDb();
const provider = db
.prepare("SELECT isActive, type FROM provider_instances WHERE id = ?")
.get(id) as { isActive: number; type: string } | undefined;
db.prepare("DELETE FROM provider_instances WHERE id = ?").run(id);
if (provider && provider.isActive === 1) {
const next = db
.prepare("SELECT id FROM provider_instances WHERE type = ? LIMIT 1")
.get(provider.type) as { id: string } | undefined;
if (next) {
db.prepare(
"UPDATE provider_instances SET isActive = 1 WHERE id = ?",
).run(next.id);
}
} finally {
db.close();
}
}
static setActive(id: string): void {
const db = getSettingsDb();
try {
const target = db
.prepare(`SELECT type FROM provider_instances WHERE id = ?`)
.get(id) as { type: string } | undefined;
if (target) {
db.prepare(
`UPDATE provider_instances SET isActive = 0 WHERE type = ?`,
).run(target.type);
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(id);
}
} finally {
db.close();
const db = getDb();
const target = db
.prepare("SELECT type FROM provider_instances WHERE id = ?")
.get(id) as { type: string } | undefined;
if (target) {
db.prepare(
"UPDATE provider_instances SET isActive = 0 WHERE type = ?",
).run(target.type);
db.prepare("UPDATE provider_instances SET isActive = 1 WHERE id = ?").run(
id,
);
}
}
@@ -299,329 +148,99 @@ export class ProviderManager {
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): void {
const db = getSettingsDb();
try {
const actualMaxContext =
maxContext !== undefined
? maxContext
: type === "generative"
? 32768
: 0;
if (apiKey && apiKey.trim()) {
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`,
).run(
name,
providerName,
apiKey,
modelName || null,
type,
actualMaxContext,
id,
);
} else {
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`,
).run(
name,
providerName,
modelName || null,
type,
actualMaxContext,
id,
);
}
} finally {
db.close();
const db = getDb();
const actualMaxContext =
maxContext !== undefined ? maxContext : type === "generative" ? 32768 : 0;
if (apiKey && apiKey.trim()) {
db.prepare(
`UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
WHERE id = ?`,
).run(
name,
providerName,
apiKey,
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
id,
);
} else {
db.prepare(
`UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
WHERE id = ?`,
).run(
name,
providerName,
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
id,
);
}
}
static getActive(
type: "generative" | "embedding" = "generative",
): ModelProviderInstance | null {
const db = getSettingsDb();
const db = getDb();
try {
const row = db
.prepare(
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
"SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?",
)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
.get(type) as DbRow | undefined;
if (!row) {
const totalCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
.get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"Gemini (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-2.5-flash",
"generative",
32768,
);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
embedId,
"Gemini Embed (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-embedding-001",
"embedding",
0,
);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"OpenRouter (Env)",
"openrouter",
openRouterKey.trim(),
isActive,
"google/gemini-2.5-flash",
"generative",
32768,
);
}
const retryRow = db
.prepare(
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
if (retryRow) {
return {
id: retryRow.id,
name: retryRow.name,
providerName: retryRow.providerName,
apiKey: retryRow.apiKey,
isActive: true,
modelName: retryRow.modelName || undefined,
type: retryRow.type as "generative" | "embedding",
maxContext:
retryRow.maxContext !== undefined &&
retryRow.maxContext !== null
? retryRow.maxContext
: retryRow.type === "embedding"
? 0
: 32768,
};
}
}
// If there's no active row but some rows exist, return the first one as active, or update it
const firstRow = db
.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
if (firstRow) {
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(firstRow.id);
return {
id: firstRow.id,
name: firstRow.name,
providerName: firstRow.providerName,
apiKey: firstRow.apiKey,
isActive: true,
modelName: firstRow.modelName || undefined,
type: firstRow.type as "generative" | "embedding",
maxContext:
firstRow.maxContext !== undefined && firstRow.maxContext !== null
? firstRow.maxContext
: firstRow.type === "embedding"
? 0
: 32768,
};
}
return null;
if (row) {
return mapRow(row);
}
return {
id: row.id,
name: row.name,
providerName: row.providerName,
apiKey: row.apiKey,
isActive: true,
modelName: row.modelName || undefined,
type: (row.type as "generative" | "embedding") || "generative",
maxContext:
row.maxContext !== undefined && row.maxContext !== null
? row.maxContext
: row.type === "embedding"
? 0
: 32768,
};
} catch {
const googleKey = process.env.GOOGLE_API_KEY;
if (type === "embedding") {
if (googleKey && googleKey.trim()) {
return {
id: "provider-default-env-embed-fallback",
name: "Gemini Embed (Env Fallback)",
providerName: "google-genai",
apiKey: googleKey.trim(),
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
maxContext: 0,
};
}
return null;
const firstRow = db
.prepare("SELECT * FROM provider_instances WHERE type = ? LIMIT 1")
.get(type) as DbRow | undefined;
if (firstRow) {
db.prepare(
"UPDATE provider_instances SET isActive = 1 WHERE id = ?",
).run(firstRow.id);
return mapRow(firstRow);
}
// generative fallback
if (googleKey && googleKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "Gemini (Env Fallback)",
providerName: "google-genai",
apiKey: googleKey.trim(),
isActive: true,
modelName: "gemini-2.5-flash",
type: "generative",
maxContext: 32768,
};
}
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (openRouterKey && openRouterKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "OpenRouter (Env Fallback)",
providerName: "openrouter",
apiKey: openRouterKey.trim(),
isActive: true,
modelName: "google/gemini-2.5-flash",
type: "generative",
maxContext: 32768,
};
}
return null;
} finally {
db.close();
} catch {
return null;
}
}
static getMappings(): Record<string, string> {
const db = getSettingsDb();
try {
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`,
).run();
const rows = db.prepare(`SELECT * FROM provider_mappings`).all() as {
task: string;
providerInstanceId: string;
}[];
const mappings: Record<string, string> = {};
for (const row of rows) {
mappings[row.task] = row.providerInstanceId;
}
return mappings;
} finally {
db.close();
const db = getDb();
const rows = db.prepare("SELECT * FROM provider_mappings").all() as {
task: string;
providerInstanceId: string;
}[];
const mappings: Record<string, string> = {};
for (const row of rows) {
mappings[row.task] = row.providerInstanceId;
}
return mappings;
}
static setMapping(task: string, providerInstanceId: string): void {
const db = getSettingsDb();
try {
const db = getDb();
if (!providerInstanceId) {
db.prepare("DELETE FROM provider_mappings WHERE task = ?").run(task);
} else {
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`,
).run();
if (!providerInstanceId) {
db.prepare(`DELETE FROM provider_mappings WHERE task = ?`).run(task);
} else {
db.prepare(
`
INSERT INTO provider_mappings (task, providerInstanceId)
VALUES (?, ?)
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId
`,
).run(task, providerInstanceId);
}
} finally {
db.close();
`INSERT INTO provider_mappings (task, providerInstanceId)
VALUES (?, ?)
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId`,
).run(task, providerInstanceId);
}
}
}

View File

@@ -0,0 +1,116 @@
import { ChatAnthropic } from "@langchain/anthropic";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.js";
async function fetchAnthropicModels(apiKey: string): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
let afterId: string | undefined;
do {
const url = new URL("https://api.anthropic.com/v1/models");
url.searchParams.set("limit", "1000");
if (afterId) {
url.searchParams.set("after_id", afterId);
}
const res = await fetchWithTimeout(url.toString(), {
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
Accept: "application/json",
},
});
if (!res.ok) return models;
const json = (await res.json()) as {
data?: { id: string; display_name?: string }[];
has_more?: boolean;
last_id?: string;
};
for (const m of json.data ?? []) {
models.push({ id: m.id, name: m.display_name || m.id });
}
afterId = json.has_more ? json.last_id : undefined;
} while (afterId);
return models;
}
export class AnthropicProvider extends BaseLLMProvider {
static {
registerProvider({
id: "anthropic",
displayName: "Anthropic Claude",
description: "Official Claude integration using @langchain/anthropic SDK",
envVar: "ANTHROPIC_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "claude-3-5-sonnet-latest",
defaultMaxContext: 200000,
fallbackPriority: 2,
listModels: fetchAnthropicModels,
});
registerGenerative(
"anthropic",
(inst: ModelProviderInstance) =>
new AnthropicProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new AnthropicProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "Anthropic";
protected readonly model: ChatAnthropic;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 200000;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "anthropic",
envVarName: "ANTHROPIC_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"ANTHROPIC_API_KEY is required to initialize AnthropicProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "claude-3-5-sonnet-latest";
this.model = new ChatAnthropic({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -0,0 +1,82 @@
import { ChatDeepSeek } from "@langchain/deepseek";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchOpenAICompatibleModels } from "../model-lister.js";
export class DeepSeekProvider extends BaseLLMProvider {
static {
registerProvider({
id: "deepseek",
displayName: "DeepSeek",
description:
"Official DeepSeek integration using @langchain/deepseek SDK",
envVar: "DEEPSEEK_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "deepseek-chat",
defaultMaxContext: 64000,
fallbackPriority: 4,
listModels: (apiKey) =>
fetchOpenAICompatibleModels("https://api.deepseek.com", apiKey),
});
registerGenerative(
"deepseek",
(inst: ModelProviderInstance) =>
new DeepSeekProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new DeepSeekProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "DeepSeek";
protected readonly model: ChatDeepSeek;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 64000;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "deepseek",
envVarName: "DEEPSEEK_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"DEEPSEEK_API_KEY is required to initialize DeepSeekProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "deepseek-chat";
this.model = new ChatDeepSeek({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -1,31 +1,96 @@
import { z } from "zod";
import {
ChatGoogleGenerativeAI,
GoogleGenerativeAIEmbeddings,
} from "@langchain/google-genai";
import {
import type {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
IEmbeddingProvider,
ModelProviderInstance,
} from "../llm.js";
import { llmConfig } from "../config.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { getLlmConfig } 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";
async function fetchGeminiModels(apiKey: string): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
let pageToken: string | undefined;
do {
const url = new URL(
"https://generativelanguage.googleapis.com/v1beta/models",
);
url.searchParams.set("key", apiKey);
url.searchParams.set("pageSize", "100");
if (pageToken) {
url.searchParams.set("pageToken", pageToken);
}
const res = await fetchWithTimeout(url.toString());
if (!res.ok) return models;
const json = (await res.json()) as {
models?: { name: string; displayName?: string }[];
nextPageToken?: string;
};
for (const m of json.models ?? []) {
const id = m.name.replace(/^models\//, "");
models.push({ id, name: m.displayName || id });
}
pageToken = json.nextPageToken;
} while (pageToken);
return models;
}
export class GeminiProvider extends BaseLLMProvider {
static {
registerProvider({
id: "google-genai",
displayName: "Google Gemini",
description: "Official Gemini integration using Google Gen AI SDK",
envVar: "GOOGLE_API_KEY",
capabilities: { generative: true, embedding: true },
defaultModel: "gemini-2.5-flash",
defaultEmbeddingModel: "gemini-embedding-001",
defaultMaxContext: 32768,
fallbackPriority: 0,
listModels: fetchGeminiModels,
});
registerGenerative(
"google-genai",
(inst: ModelProviderInstance) =>
new GeminiProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
providerName = "Gemini";
private model: ChatGoogleGenerativeAI;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
protected readonly model: ChatGoogleGenerativeAI;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected readonly defaultMaxContext = 32768;
static create(inst: ModelProviderInstance): ILLMProvider {
return new GeminiProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
constructor(
apiKey?: string,
@@ -33,113 +98,66 @@ export class GeminiProvider implements ILLMProvider {
providerInstanceName?: string,
maxContext?: number,
) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!key) {
const active = ProviderManager.getActive("generative");
if (active && active.providerName === GeminiProvider.providerId) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
if (!key) {
key = llmConfig.GOOGLE_API_KEY;
if (!this.providerInstanceName && key) {
this.providerInstanceName = "Environment Variable";
}
}
super();
const {
key,
model,
providerInstanceName: pn,
maxContext: mc,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "google-genai",
envVarName: "GOOGLE_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"GOOGLE_API_KEY is required to initialize GeminiProvider",
);
}
this.providerInstanceName = pn;
this.maxContextUsed = mc;
this.modelNameUsed = model || "gemini-2.5-flash";
this.model = new ChatGoogleGenerativeAI({
apiKey: key,
model: this.modelNameUsed,
});
}
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 = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext:
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
}
}
export class GeminiEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "google-genai";
static readonly displayName = "Google Gemini Embeddings";
static {
registerEmbedding(
"google-genai",
(inst: ModelProviderInstance) =>
new GeminiEmbeddingProvider(inst.apiKey, inst.modelName),
);
}
providerName = "Gemini";
private model: GoogleGenerativeAIEmbeddings;
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
}
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive("embedding");
if (active) {
if (active && active.providerName === "google-genai") {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!model) model = active.modelName;
}
}
if (!key) {
key = llmConfig.GOOGLE_API_KEY;
key = getLlmConfig().GOOGLE_API_KEY;
}
if (!key) {

View File

@@ -0,0 +1,79 @@
import { ChatGroq } from "@langchain/groq";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchOpenAICompatibleModels } from "../model-lister.js";
export class GroqProvider extends BaseLLMProvider {
static {
registerProvider({
id: "groq",
displayName: "Groq",
description: "Official Groq integration using @langchain/groq SDK",
envVar: "GROQ_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "llama-3.3-70b-versatile",
defaultMaxContext: 8192,
fallbackPriority: 3,
listModels: (apiKey) =>
fetchOpenAICompatibleModels("https://api.groq.com/openai/v1", apiKey),
});
registerGenerative(
"groq",
(inst: ModelProviderInstance) =>
new GroqProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new GroqProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "Groq";
protected readonly model: ChatGroq;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 8192;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "groq",
envVarName: "GROQ_API_KEY",
type: "generative",
});
if (!key) {
throw new Error("GROQ_API_KEY is required to initialize GroqProvider");
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "llama-3.3-70b-versatile";
this.model = new ChatGroq({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -6,13 +6,34 @@ import {
LLMCallRecord,
IEmbeddingProvider,
} from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.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";
static {
registerProvider({
id: "mock",
displayName: "Mock LLM Provider",
description:
"Stateless mock provider for testing and offline development",
capabilities: { generative: true, embedding: true },
defaultModel: "mock",
defaultEmbeddingModel: "mock-embeddings",
defaultMaxContext: 0,
fallbackPriority: 1000,
listModels: () => Promise.resolve([{ id: "mock", name: "Mock Model" }]),
});
registerGenerative("mock", () => new MockLLMProvider([]));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static create(inst: ModelProviderInstance): ILLMProvider {
return new MockLLMProvider([]);
}
providerName = "mock";
private callCount = 0;
@@ -28,15 +49,21 @@ export class MockLLMProvider implements ILLMProvider {
return { success: false, error: "Mock responses exhausted" };
}
const usage = { inputTokens: 100, outputTokens: 50, totalTokens: 150 };
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
try {
const parsed = request.schema.parse(next);
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
response: parsed,
});
return { success: true, data: parsed, usage };
} catch (e) {
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return {
success: false,
error: e instanceof Error ? e.message : String(e),
@@ -46,7 +73,17 @@ export class MockLLMProvider implements ILLMProvider {
}
export class MockEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "mock";
static {
registerEmbedding(
"mock",
(inst: ModelProviderInstance) =>
new MockEmbeddingProvider(inst.modelName),
);
}
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new MockEmbeddingProvider(inst.modelName);
}
providerName = "mock";

View File

@@ -0,0 +1,172 @@
import { ChatOllama, OllamaEmbeddings } from "@langchain/ollama";
import { ILLMProvider, IEmbeddingProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { ProviderManager } from "../provider-manager.js";
import { BaseLLMProvider } from "../base-provider.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.js";
async function fetchOllamaModels(endpointUrl: string): Promise<ModelInfo[]> {
const base = endpointUrl.replace(/\/$/, "");
const res = await fetchWithTimeout(`${base}/api/tags`);
if (!res.ok) return [];
const json = (await res.json()) as {
models?: { name: string; model?: string }[];
};
return (json.models ?? []).map((m) => ({
id: m.name,
name: m.name,
}));
}
export class OllamaProvider extends BaseLLMProvider {
static {
registerProvider({
id: "ollama",
displayName: "Ollama",
description:
"Local model runner supporting open-source LLMs via the Ollama server",
capabilities: { generative: true, embedding: true },
defaultModel: "llama3.1",
defaultEmbeddingModel: "nomic-embed-text",
defaultMaxContext: 32768,
fallbackPriority: 100,
listModels: (_apiKey, endpointUrl) =>
fetchOllamaModels(endpointUrl || "http://localhost:11434"),
});
registerGenerative(
"ollama",
(inst: ModelProviderInstance) =>
new OllamaProvider(
inst.endpointUrl,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new OllamaProvider(
inst.endpointUrl,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "Ollama";
protected readonly model: ChatOllama;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 32768;
/**
* Creates an OllamaProvider.
*
* Resolution order for configuration:
* 1. Explicit constructor arguments
* 2. Active "generative" instance in ProviderManager whose providerName === "ollama"
* 3. Defaults (baseUrl: http://localhost:11434, model: llama3.1)
*
* No API key is required for Ollama. The `endpointUrl` in
* ModelProviderInstance stores the Ollama server base URL
* (e.g. "http://localhost:11434").
*/
constructor(
baseUrl?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
let url = baseUrl;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!url || !model) {
const active = ProviderManager.getActive("generative");
if (active && active.providerName === "ollama") {
if (!url) {
url = active.endpointUrl;
}
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
this.modelNameUsed = model || "llama3.1";
this.model = new ChatOllama({
baseUrl: url || "http://localhost:11434",
model: this.modelNameUsed,
});
}
}
export class OllamaEmbeddingProvider implements IEmbeddingProvider {
static {
registerEmbedding(
"ollama",
(inst: ModelProviderInstance) =>
new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName),
);
}
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName);
}
providerName = "Ollama";
private model: OllamaEmbeddings;
/**
* Creates an OllamaEmbeddingProvider.
*
* Resolution order:
* 1. Explicit constructor arguments
* 2. Active "embedding" instance in ProviderManager
* 3. Defaults (baseUrl: http://localhost:11434, model: nomic-embed-text)
*
* The `endpointUrl` field in ModelProviderInstance stores the base URL.
*/
constructor(baseUrl?: string, modelName?: string) {
let url = baseUrl;
let model = modelName;
if (!url || !model) {
const active = ProviderManager.getActive("embedding");
if (active && active.providerName === "ollama") {
if (!url) {
url = active.endpointUrl;
}
if (!model) {
model = active.modelName;
}
}
}
this.model = new OllamaEmbeddings({
baseUrl: url || "http://localhost:11434",
model: model || "nomic-embed-text",
});
}
async embed(text: string): Promise<number[]> {
return this.model.embedQuery(text);
}
}

View File

@@ -0,0 +1,139 @@
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { ILLMProvider, IEmbeddingProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { getLlmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import {
registerProvider,
registerGenerative,
registerEmbedding,
} from "../registry.js";
import { fetchOpenAICompatibleModels } from "../model-lister.js";
export class OpenAIProvider extends BaseLLMProvider {
static {
registerProvider({
id: "openai",
displayName: "OpenAI",
description: "Official OpenAI integration using @langchain/openai SDK",
envVar: "OPENAI_API_KEY",
capabilities: { generative: true, embedding: true },
defaultModel: "gpt-4o-mini",
defaultEmbeddingModel: "text-embedding-3-small",
defaultMaxContext: 128000,
fallbackPriority: 1,
listModels: (apiKey) =>
fetchOpenAICompatibleModels("https://api.openai.com/v1", apiKey),
});
registerGenerative(
"openai",
(inst: ModelProviderInstance) =>
new OpenAIProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new OpenAIProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "OpenAI";
protected readonly model: ChatOpenAI;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 128000;
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "openai",
envVarName: "OPENAI_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"OPENAI_API_KEY is required to initialize OpenAIProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "gpt-4o-mini";
this.model = new ChatOpenAI({ apiKey: key, model: this.modelNameUsed });
}
}
export class OpenAIEmbeddingProvider implements IEmbeddingProvider {
static {
registerEmbedding(
"openai",
(inst: ModelProviderInstance) =>
new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName),
);
}
static create(inst: ModelProviderInstance): IEmbeddingProvider {
return new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName);
}
providerName = "OpenAI";
private model: OpenAIEmbeddings;
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive("embedding");
if (active && active.providerName === "openai") {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
}
}
if (!key) {
key = getLlmConfig().OPENAI_API_KEY;
}
if (!key) {
throw new Error(
"OPENAI_API_KEY is required to initialize OpenAIEmbeddingProvider",
);
}
this.model = new OpenAIEmbeddings({
apiKey: key,
model: model || "text-embedding-3-small",
});
}
async embed(text: string): Promise<number[]> {
return this.model.embedQuery(text);
}
}

View File

@@ -1,27 +1,77 @@
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";
import { ILLMProvider } from "../llm.js";
import type { ModelProviderInstance } from "../llm.js";
import { BaseLLMProvider, resolveCredentials } from "../base-provider.js";
import { registerProvider, registerGenerative } from "../registry.js";
import { fetchWithTimeout, type ModelInfo } from "../model-lister.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";
async function fetchOpenRouterModels(apiKey: string): Promise<ModelInfo[]> {
const res = await fetchWithTimeout(
"https://openrouter.ai/api/v1/models",
apiKey
? {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
}
: { headers: { Accept: "application/json" } },
);
if (!res.ok) return [];
const json = (await res.json()) as {
data?: { id: string; name?: string; owned_by?: string }[];
};
return (json.data ?? []).map((m) => ({
id: m.id,
name: m.name || m.id,
ownedBy: m.owned_by,
}));
}
export class OpenRouterProvider extends BaseLLMProvider {
static {
registerProvider({
id: "openrouter",
displayName: "OpenRouter",
description:
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
envVar: "OPENROUTER_API_KEY",
capabilities: { generative: true, embedding: false },
defaultModel: "google/gemini-2.5-flash",
defaultEmbeddingModel: "openai/text-embedding-3-small",
defaultMaxContext: 32768,
fallbackPriority: 5,
listModels: fetchOpenRouterModels,
});
registerGenerative(
"openrouter",
(inst: ModelProviderInstance) =>
new OpenRouterProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
),
);
}
static create(inst: ModelProviderInstance): ILLMProvider {
return new OpenRouterProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
providerName = "OpenRouter";
private model: ChatOpenRouter;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
protected readonly model: ChatOpenRouter;
protected modelNameUsed: string;
protected providerInstanceName?: string;
protected maxContextUsed?: number;
protected defaultMaxContext = 32768;
constructor(
apiKey?: string,
@@ -29,86 +79,29 @@ export class OpenRouterProvider implements ILLMProvider {
providerInstanceName?: string,
maxContext?: number,
) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!key) {
const active = ProviderManager.getActive("generative");
if (active && active.providerName === OpenRouterProvider.providerId) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
if (!key) {
key = llmConfig.OPENROUTER_API_KEY;
if (!this.providerInstanceName && key) {
this.providerInstanceName = "Environment Variable";
}
}
super();
const {
key,
model,
providerInstanceName: resolvedName,
maxContext: resolvedMax,
} = resolveCredentials({
explicitKey: apiKey,
explicitModel: modelName,
explicitProviderInstanceName: providerInstanceName,
explicitMaxContext: maxContext,
providerId: "openrouter",
envVarName: "OPENROUTER_API_KEY",
type: "generative",
});
if (!key) {
throw new Error(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
);
}
this.providerInstanceName = resolvedName;
this.maxContextUsed = resolvedMax;
this.modelNameUsed = model || "google/gemini-2.5-flash";
this.model = new ChatOpenRouter({
apiKey: key,
model: this.modelNameUsed,
});
}
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 = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext:
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,
userContext: request.userContext,
usage,
});
return { success: true, data: parsed, usage };
this.model = new ChatOpenRouter({ apiKey: key, model: this.modelNameUsed });
}
}

View File

@@ -0,0 +1,79 @@
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
ModelProviderMeta,
} from "./llm.js";
import type { ModelInfo } from "./model-lister.js";
export interface ProviderDefinition {
id: string;
displayName: string;
description: string;
envVar?: string;
capabilities: { generative: boolean; embedding: boolean };
defaultModel: string;
defaultEmbeddingModel?: string;
defaultMaxContext: number;
fallbackPriority: number;
listModels?: (apiKey: string, endpointUrl?: string) => Promise<ModelInfo[]>;
generativeCreate?: (inst: ModelProviderInstance) => ILLMProvider;
embeddingCreate?: (inst: ModelProviderInstance) => IEmbeddingProvider;
}
const _entries = new Map<string, ProviderDefinition>();
type ProviderMeta = Omit<
ProviderDefinition,
"generativeCreate" | "embeddingCreate"
>;
export function registerProvider(meta: ProviderMeta) {
const existing = _entries.get(meta.id);
_entries.set(meta.id, {
...existing,
...meta,
generativeCreate: existing?.generativeCreate,
embeddingCreate: existing?.embeddingCreate,
});
}
export function registerGenerative(
id: string,
createFn: (inst: ModelProviderInstance) => ILLMProvider,
) {
const existing = _entries.get(id);
if (existing) {
existing.generativeCreate = createFn;
} else {
_entries.set(id, { id, generativeCreate: createFn } as ProviderDefinition);
}
}
export function registerEmbedding(
id: string,
createFn: (inst: ModelProviderInstance) => IEmbeddingProvider,
) {
const existing = _entries.get(id);
if (existing) {
existing.embeddingCreate = createFn;
} else {
_entries.set(id, { id, embeddingCreate: createFn } as ProviderDefinition);
}
}
export const ProviderRegistry = {
all: (): ProviderDefinition[] => [..._entries.values()],
get: (id: string): ProviderDefinition | undefined => _entries.get(id),
has: (id: string): boolean => _entries.has(id),
} as const;
export function toProviderMeta(def: ProviderDefinition): ModelProviderMeta {
return {
id: def.id,
displayName: def.displayName,
description: def.description,
defaultModel: def.defaultModel,
defaultEmbeddingModel: def.defaultEmbeddingModel || "",
};
}

View File

@@ -0,0 +1,32 @@
import type { ModelProviderInstance } from "./llm.js";
export type DbRow = {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
endpointUrl?: string;
};
export function mapRow(r: DbRow): ModelProviderInstance {
return {
id: r.id,
name: r.name,
providerName: r.providerName,
apiKey: r.apiKey,
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
type: (r.type as "generative" | "embedding") || "generative",
maxContext:
r.maxContext !== undefined && r.maxContext !== null
? r.maxContext
: r.type === "embedding"
? 0
: 32768,
endpointUrl: r.endpointUrl || undefined,
};
}

View File

@@ -0,0 +1,126 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
describe("setup-provider CLI Tool Tests", () => {
let tempDbPath: string;
let scriptPath: string;
beforeEach(() => {
// Generate a unique temp database path
tempDbPath = path.resolve(
process.cwd(),
`test-cli-${Date.now()}-${Math.random().toString(36).substring(2)}.db`,
);
scriptPath = path.resolve(
process.cwd(),
"packages/llm/dist/bin/setup-provider.js",
);
});
afterEach(() => {
if (fs.existsSync(tempDbPath)) {
try {
fs.unlinkSync(tempDbPath);
} catch {
// ignore
}
}
});
test("prints help message when --help or -h is passed", () => {
const stdout = execSync(`node ${scriptPath} --help`).toString();
expect(stdout).toContain("Usage:");
expect(stdout).toContain("Options:");
expect(stdout).toContain("Registered Providers:");
});
test("creates a provider instance successfully via CLI flags", () => {
const cmd = `node ${scriptPath} --provider google-genai --key mock-key-abc --name "Test Gemini" --model "gemini-2.5-flash"`;
const stdout = execSync(cmd, {
env: { ...process.env, OMNIA_DB_PATH: tempDbPath },
}).toString();
expect(stdout).toContain("Successfully created provider instance:");
expect(stdout).toContain("Test Gemini");
expect(stdout).toContain("google-genai");
expect(stdout).toContain("mock-key-abc");
// Read the SQLite db directly to verify
const db = new Database(tempDbPath);
const rows = db.prepare("SELECT * FROM provider_instances").all() as {
name: string;
providerName: string;
apiKey: string;
modelName: string;
isActive: number;
}[];
expect(rows.length).toBe(1);
expect(rows[0].name).toBe("Test Gemini");
expect(rows[0].providerName).toBe("google-genai");
expect(rows[0].apiKey).toBe("mock-key-abc");
expect(rows[0].modelName).toBe("gemini-2.5-flash");
expect(rows[0].isActive).toBe(1);
db.close();
});
test("fails when required key is missing and env var is not set", () => {
let error: { status?: number; stderr?: Buffer } | undefined;
try {
execSync(`node ${scriptPath} --provider google-genai`, {
env: { ...process.env, OMNIA_DB_PATH: tempDbPath, GOOGLE_API_KEY: "" },
stdio: "pipe",
});
} catch (e) {
error = e as { status?: number; stderr?: Buffer };
}
expect(error).toBeDefined();
expect(error?.status).toBe(1);
expect(error?.stderr?.toString()).toContain("Error: API Key is required");
});
test("seeds from environment variables when using --all", () => {
const cmd = `node ${scriptPath} --all`;
const stdout = execSync(cmd, {
env: {
...process.env,
OMNIA_DB_PATH: tempDbPath,
GOOGLE_API_KEY: "mock-google-key-all",
OPENAI_API_KEY: "",
ANTHROPIC_API_KEY: "",
GROQ_API_KEY: "",
DEEPSEEK_API_KEY: "",
OPENROUTER_API_KEY: "",
},
}).toString();
expect(stdout).toContain(
"Created generative instance: Google Gemini (CLI)",
);
expect(stdout).toContain(
"Created embedding instance: Google Gemini Embed (CLI)",
);
const db = new Database(tempDbPath);
const rows = db.prepare("SELECT * FROM provider_instances").all() as {
type: string;
apiKey: string;
modelName: string;
}[];
// Should have both generative and embedding instances
expect(rows.length).toBe(2);
const gen = rows.find((r) => r.type === "generative");
const embed = rows.find((r) => r.type === "embedding");
expect(gen).toBeDefined();
expect(gen?.apiKey).toBe("mock-google-key-all");
expect(gen?.modelName).toBe("gemini-2.5-flash");
expect(embed).toBeDefined();
expect(embed?.apiKey).toBe("mock-google-key-all");
expect(embed?.modelName).toBe("gemini-embedding-001");
db.close();
});
});

View File

@@ -0,0 +1,128 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import { DeepSeekProvider } from "../src/providers/deepseek.js";
// Mock the ChatDeepSeek class
vi.mock("@langchain/deepseek", () => {
return {
ChatDeepSeek: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
};
});
describe("DeepSeekProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new DeepSeekProvider("dummy-key");
expect(provider.providerName).toBe("DeepSeek");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.DEEPSEEK_API_KEY;
process.env.DEEPSEEK_API_KEY = "env-dummy-key";
mockConfig.DEEPSEEK_API_KEY = "env-dummy-key";
try {
const provider = new DeepSeekProvider();
expect(provider.providerName).toBe("DeepSeek");
} finally {
process.env.DEEPSEEK_API_KEY = originalKey;
delete mockConfig.DEEPSEEK_API_KEY;
}
});
test("throws error if no API key is provided or in config", () => {
const originalKey = process.env.DEEPSEEK_API_KEY;
process.env.DEEPSEEK_API_KEY = undefined;
mockConfig.DEEPSEEK_API_KEY = undefined;
try {
expect(() => new DeepSeekProvider()).toThrow(
"DEEPSEEK_API_KEY is required to initialize DeepSeekProvider",
);
} finally {
process.env.DEEPSEEK_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new DeepSeekProvider("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,
modelName: "deepseek-chat",
providerInstanceName: "Default",
maxContext: 64000,
});
expect(provider.lastCalls.length).toBe(1);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import { GroqProvider } from "../src/providers/groq.js";
// Mock the ChatGroq class
vi.mock("@langchain/groq", () => {
return {
ChatGroq: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
};
});
describe("GroqProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new GroqProvider("dummy-key");
expect(provider.providerName).toBe("Groq");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.GROQ_API_KEY;
process.env.GROQ_API_KEY = "env-dummy-key";
mockConfig.GROQ_API_KEY = "env-dummy-key";
try {
const provider = new GroqProvider();
expect(provider.providerName).toBe("Groq");
} finally {
process.env.GROQ_API_KEY = originalKey;
delete mockConfig.GROQ_API_KEY;
}
});
test("throws error if no API key is provided or in config", () => {
const originalKey = process.env.GROQ_API_KEY;
process.env.GROQ_API_KEY = undefined;
mockConfig.GROQ_API_KEY = undefined;
try {
expect(() => new GroqProvider()).toThrow(
"GROQ_API_KEY is required to initialize GroqProvider",
);
} finally {
process.env.GROQ_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new GroqProvider("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,
modelName: "llama-3.3-70b-versatile",
providerInstanceName: "Default",
maxContext: 8192,
});
expect(provider.lastCalls.length).toBe(1);
});
});

View File

@@ -0,0 +1,120 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { ModelLister } from "@omnia/llm";
describe("ModelLister Unit Tests (Tier 1)", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
ModelLister.clearCache();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("returns mock provider list instantly without fetch", async () => {
const models = await ModelLister.listModels("mock", "none");
expect(models).toEqual([{ id: "mock", name: "Mock Model" }]);
expect(fetch).not.toHaveBeenCalled();
});
test("fetches and caches OpenAI-compatible models", async () => {
const mockResponse = {
data: [
{ id: "gpt-4o", owned_by: "openai" },
{ id: "gpt-4o-mini", owned_by: "openai" },
],
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
vi.stubGlobal("fetch", mockFetch);
// First call: Should fetch
const models = await ModelLister.listModels("openai", "test-key");
expect(models).toEqual([
{ id: "gpt-4o", name: "gpt-4o", ownedBy: "openai" },
{ id: "gpt-4o-mini", name: "gpt-4o-mini", ownedBy: "openai" },
]);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.openai.com/v1/models",
expect.objectContaining({
headers: {
Authorization: "Bearer test-key",
Accept: "application/json",
},
}),
);
// Second call: Should read from cache
const cachedModels = await ModelLister.listModels("openai", "test-key");
expect(cachedModels).toEqual(models);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
test("respects cache invalidation", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "model-1" }] }),
});
vi.stubGlobal("fetch", mockFetch);
await ModelLister.listModels("openai", "test-key");
expect(mockFetch).toHaveBeenCalledTimes(1);
// Invalidate
ModelLister.invalidateCache("openai", "test-key");
// Second call: Should fetch again
await ModelLister.listModels("openai", "test-key");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
test("gracefully returns empty array on fetch failure", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
vi.stubGlobal("fetch", mockFetch);
const models = await ModelLister.listModels("openai", "bad-key");
expect(models).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
test("handles Gemini pagination correctly", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
models: [
{
name: "models/gemini-2.5-flash",
displayName: "Gemini 2.5 Flash",
},
],
nextPageToken: "token-1",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
models: [
{ name: "models/gemini-2.5-pro", displayName: "Gemini 2.5 Pro" },
],
}),
});
vi.stubGlobal("fetch", mockFetch);
const models = await ModelLister.listModels("google-genai", "gemini-key");
expect(models).toEqual([
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
]);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,168 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import {
OpenAIProvider,
OpenAIEmbeddingProvider,
} from "../src/providers/openai.js";
// Mock the ChatOpenAI and OpenAIEmbeddings classes
vi.mock("@langchain/openai", () => {
return {
ChatOpenAI: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
withStructuredOutput = vi.fn().mockImplementation(() => {
return {
invoke: vi.fn().mockImplementation(async () => {
return {
parsed: {
name: "mocked response",
success: true,
},
raw: {
usage_metadata: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
},
};
}),
};
});
},
OpenAIEmbeddings: class {
config: unknown;
constructor(config: unknown) {
this.config = config;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
embedQuery = vi.fn().mockImplementation(async (text: string) => {
return [0.1, 0.2, 0.3];
});
},
};
});
describe("OpenAIProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new OpenAIProvider("dummy-key");
expect(provider.providerName).toBe("OpenAI");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "env-dummy-key";
mockConfig.OPENAI_API_KEY = "env-dummy-key";
try {
const provider = new OpenAIProvider();
expect(provider.providerName).toBe("OpenAI");
} finally {
process.env.OPENAI_API_KEY = originalKey;
delete mockConfig.OPENAI_API_KEY;
}
});
test("throws error if no API key is provided or in config", () => {
const originalKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = undefined;
mockConfig.OPENAI_API_KEY = undefined;
try {
expect(() => new OpenAIProvider()).toThrow(
"OPENAI_API_KEY is required to initialize OpenAIProvider",
);
} finally {
process.env.OPENAI_API_KEY = originalKey;
}
});
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
const provider = new OpenAIProvider("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,
modelName: "gpt-4o-mini",
providerInstanceName: "Default",
maxContext: 128000,
});
expect(provider.lastCalls.length).toBe(1);
});
});
describe("OpenAIEmbeddingProvider Unit Tests (Tier 1)", () => {
test("initializes successfully with a provided apiKey", () => {
const provider = new OpenAIEmbeddingProvider("dummy-key");
expect(provider.providerName).toBe("OpenAI");
});
test("initializes successfully with apiKey from config", () => {
const originalKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "env-dummy-key";
mockConfig.OPENAI_API_KEY = "env-dummy-key";
try {
const provider = new OpenAIEmbeddingProvider();
expect(provider.providerName).toBe("OpenAI");
} finally {
process.env.OPENAI_API_KEY = originalKey;
delete mockConfig.OPENAI_API_KEY;
}
});
test("embed returns dummy array successfully", async () => {
const provider = new OpenAIEmbeddingProvider("dummy-key");
const result = await provider.embed("hello");
expect(result).toEqual([0.1, 0.2, 0.3]);
});
});

View File

@@ -1,7 +1,34 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
vi.mock("../src/config.js", () => ({
getLlmConfig: () => mockConfig,
resetLlmConfig: () => {
for (const key of Object.keys(mockConfig)) {
delete mockConfig[key];
}
},
}));
const { getActiveMock } = vi.hoisted(() => ({
getActiveMock: vi.fn().mockReturnValue(null),
}));
vi.mock("../src/provider-manager.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/provider-manager.js")>();
return {
...actual,
ProviderManager: {
...actual.ProviderManager,
getActive: getActiveMock,
},
};
});
import { OpenRouterProvider } from "../src/providers/openrouter.js";
import { llmConfig } from "../src/config.js";
// Mock the ChatOpenRouter class
vi.mock("@langchain/openrouter", () => {
@@ -14,7 +41,6 @@ vi.mock("@langchain/openrouter", () => {
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",
@@ -42,29 +68,30 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
});
test("initializes successfully with apiKey from config", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
const originalKey = process.env.OPENROUTER_API_KEY;
process.env.OPENROUTER_API_KEY = "env-dummy-key";
mockConfig.OPENROUTER_API_KEY = "env-dummy-key";
try {
const provider = new OpenRouterProvider();
expect(provider.providerName).toBe("OpenRouter");
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
process.env.OPENROUTER_API_KEY = originalKey;
delete mockConfig.OPENROUTER_API_KEY;
}
});
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;
const originalKey = process.env.OPENROUTER_API_KEY;
process.env.OPENROUTER_API_KEY = undefined;
mockConfig.OPENROUTER_API_KEY = undefined;
try {
expect(() => new OpenRouterProvider()).toThrow(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
);
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;
process.env.OPENROUTER_API_KEY = originalKey;
}
});
@@ -100,6 +127,10 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
expect(provider.lastCalls[0]).toEqual({
systemPrompt: "system prompt",
userContext: "user context",
response: {
name: "mocked response",
success: true,
},
usage: {
inputTokens: 10,
outputTokens: 5,

View File

@@ -1,24 +1,27 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import {
ProviderManager,
setDbPathOverride,
resetHasBootstrapped,
} from "../src/index.js";
import { ProviderManager, setDbPathOverride } from "../src/index.js";
describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
let tempDbPath: string;
let originalGoogle: string | undefined;
let originalOpenRouter: string | undefined;
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
originalGoogle = process.env.GOOGLE_API_KEY;
originalOpenRouter = process.env.OPENROUTER_API_KEY;
savedEnv = {
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GROQ_API_KEY: process.env.GROQ_API_KEY,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
};
delete process.env.GOOGLE_API_KEY;
delete process.env.OPENROUTER_API_KEY;
resetHasBootstrapped();
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.GROQ_API_KEY;
delete process.env.DEEPSEEK_API_KEY;
// Generate a unique temp database path for this test run
tempDbPath = path.resolve(
@@ -37,54 +40,189 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
// ignore
}
}
if (originalGoogle !== undefined) {
process.env.GOOGLE_API_KEY = originalGoogle;
} else {
delete process.env.GOOGLE_API_KEY;
}
if (originalOpenRouter !== undefined) {
process.env.OPENROUTER_API_KEY = originalOpenRouter;
} else {
delete process.env.OPENROUTER_API_KEY;
for (const [key, value] of Object.entries(savedEnv)) {
if (value !== undefined) {
process.env[key] = value;
} else {
delete process.env[key];
}
}
});
test("auto-bootstraps Gemini and OpenRouter when database is empty and environment variables are present", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
process.env.OPENROUTER_API_KEY = "mock-openrouter-key-456";
test("returns empty list when database is empty and no auto-bootstraps", () => {
process.env.GOOGLE_API_KEY = "mock-google-key";
const list = ProviderManager.list();
expect(list.length).toBe(3);
const gemini = list.find((p) => p.providerName === "google-genai");
expect(gemini).toBeDefined();
expect(gemini?.name).toBe("Gemini (Env)");
expect(gemini?.apiKey).toBe("mock-google-key-123");
expect(gemini?.modelName).toBe("gemini-2.5-flash");
expect(gemini?.isActive).toBe(true); // first inserted is active
const openrouter = list.find((p) => p.providerName === "openrouter");
expect(openrouter).toBeDefined();
expect(openrouter?.name).toBe("OpenRouter (Env)");
expect(openrouter?.apiKey).toBe("mock-openrouter-key-456");
expect(openrouter?.modelName).toBe("google/gemini-2.5-flash");
expect(openrouter?.isActive).toBe(false); // second inserted is inactive
expect(list.length).toBe(0);
});
test("treats bootstrapped instances as normal provider instances (editable and deletable)", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
test("getActive returns null when no providers exist and no env vars", () => {
const active = ProviderManager.getActive("generative");
expect(active).toBeNull();
const activeEmbed = ProviderManager.getActive("embedding");
expect(activeEmbed).toBeNull();
});
test("getActive returns null when DB is empty", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
const active = ProviderManager.getActive("generative");
expect(active).toBeNull();
});
test("getActive returns first instance of type when none is active", () => {
// Manually create instances without any env var bootstrap
const inst1 = ProviderManager.create("Test Gemini", "google-genai", "key1");
const inst2 = ProviderManager.create(
"Test OpenAI",
"openai",
"key2",
"gpt-4o",
"generative",
128000,
);
expect(inst1.isActive).toBe(true); // first created auto-activates
expect(inst2.isActive).toBe(false);
// Deactivate both
ProviderManager.setActive("__nonexistent__"); // no-op for nonexistent
// Deactivate inst1 by setting another as active, then delete that
ProviderManager.setActive(inst2.id);
expect(
ProviderManager.list().find((p) => p.id === inst2.id)?.isActive,
).toBe(true);
expect(
ProviderManager.list().find((p) => p.id === inst1.id)?.isActive,
).toBe(false);
// Delete the active one → auto-promotes inst1
ProviderManager.delete(inst2.id);
const promoted = ProviderManager.list().find((p) => p.id === inst1.id);
expect(promoted?.isActive).toBe(true);
});
test("setActive correctly deactivates siblings and activates target", () => {
const inst1 = ProviderManager.create(
"First Gemini",
"google-genai",
"key1",
undefined,
"generative",
);
const inst2 = ProviderManager.create(
"Second Gemini",
"google-genai",
"key2",
undefined,
"generative",
);
expect(inst1.isActive).toBe(true);
expect(inst2.isActive).toBe(false);
ProviderManager.setActive(inst2.id);
// Trigger bootstrap
const list = ProviderManager.list();
expect(list.length).toBe(2);
const bootstrapped = list.find((p) => p.name === "Gemini (Env)");
expect(bootstrapped).toBeDefined();
if (!bootstrapped) return;
expect(bootstrapped.isActive).toBe(true);
const updated1 = list.find((p) => p.id === inst1.id);
const updated2 = list.find((p) => p.id === inst2.id);
expect(updated1?.isActive).toBe(false);
expect(updated2?.isActive).toBe(true);
});
test("getMappings returns empty object initially, setMapping persists mappings", () => {
const mappings = ProviderManager.getMappings();
expect(mappings).toEqual({});
const inst = ProviderManager.create(
"Test Provider",
"google-genai",
"key1",
);
ProviderManager.setMapping("actor-prose", inst.id);
ProviderManager.setMapping("embeddings", inst.id);
const updated = ProviderManager.getMappings();
expect(updated["actor-prose"]).toBe(inst.id);
expect(updated["embeddings"]).toBe(inst.id);
});
test("setMapping with empty providerInstanceId deletes the mapping", () => {
const inst = ProviderManager.create(
"Test Provider",
"google-genai",
"key1",
);
ProviderManager.setMapping("test-task", inst.id);
expect(ProviderManager.getMappings()["test-task"]).toBe(inst.id);
ProviderManager.setMapping("test-task", "");
expect(ProviderManager.getMappings()["test-task"]).toBeUndefined();
});
test("create returns instance with correct fields and endpointUrl support", () => {
const inst = ProviderManager.create(
"Ollama Local",
"ollama",
"",
"llama3.1",
"generative",
32768,
"http://localhost:11434",
);
expect(inst.id).toMatch(/^provider-/);
expect(inst.name).toBe("Ollama Local");
expect(inst.providerName).toBe("ollama");
expect(inst.modelName).toBe("llama3.1");
expect(inst.endpointUrl).toBe("http://localhost:11434");
});
test("update preserves apiKey when not provided", () => {
const inst = ProviderManager.create(
"Original",
"openai",
"original-key",
"gpt-4o",
"generative",
128000,
);
ProviderManager.update(
inst.id,
"Renamed",
"openai",
undefined, // no apiKey → preserve existing
"gpt-4o-mini",
"generative",
64000,
);
const updated = ProviderManager.list().find((p) => p.id === inst.id);
expect(updated?.name).toBe("Renamed");
expect(updated?.apiKey).toBe("original-key"); // preserved
expect(updated?.modelName).toBe("gpt-4o-mini");
expect(updated?.maxContext).toBe(64000);
});
test("treats created instances as normal provider instances (editable and deletable)", () => {
const inst = ProviderManager.create(
"Google Gemini (Env)",
"google-genai",
"mock-google-key-123",
"gemini-2.5-flash",
"generative",
);
const list = ProviderManager.list();
expect(list.length).toBe(1);
const created = list.find((p) => p.id === inst.id);
expect(created).toBeDefined();
if (!created) return;
expect(created.isActive).toBe(true);
// Edit name and key
ProviderManager.update(
bootstrapped.id,
created.id,
"My Gemini Key",
"google-genai",
"new-secret-key",
@@ -92,8 +230,8 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
);
const listAfterUpdate = ProviderManager.list();
expect(listAfterUpdate.length).toBe(2);
const updated = listAfterUpdate.find((p) => p.id === bootstrapped.id);
expect(listAfterUpdate.length).toBe(1);
const updated = listAfterUpdate.find((p) => p.id === created.id);
expect(updated).toBeDefined();
if (!updated) return;
expect(updated.name).toBe("My Gemini Key");
@@ -101,8 +239,8 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
expect(updated.modelName).toBe("gemini-2.5-pro");
// Delete instance
ProviderManager.delete(bootstrapped.id);
ProviderManager.delete(created.id);
const listAfterDelete = ProviderManager.list();
expect(listAfterDelete.length).toBe(1);
expect(listAfterDelete.length).toBe(0);
});
});

View File

@@ -10,6 +10,7 @@
"@omnia/core": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/voice": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -1,10 +1,11 @@
import Database from "better-sqlite3";
import { Entity, resolveAlias } from "@omnia/core";
import { Entity } from "@omnia/core";
import { Intent } from "@omnia/intent";
import { hydrate } from "@omnia/voice";
export interface BufferEntry {
id: string;
ownerId: string; // Whose subjective memory buffer this lives in
ownerId: string; // Whose Cognitive Buffer this entry lives in
timestamp: string; // WorldClock.get().toISOString() at write time
locationId: string | null; // Actor's location when this happened
@@ -23,32 +24,14 @@ export function serializeSubjectiveBufferEntry(
entry: BufferEntry,
viewer: Entity,
): string {
const isSelf = viewer.id === entry.intent.actorId;
if (isSelf) {
let details = (
entry.intent.selfDescription ||
entry.intent.description ||
entry.intent.originalText
).trim();
if (details.length > 0) {
details = details.charAt(0).toUpperCase() + details.slice(1);
}
if (entry.intent.type === "action" && entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
return details;
let details = hydrate(entry.intent.content, viewer).trim();
if (details.length > 0) {
details = details.charAt(0).toUpperCase() + details.slice(1);
}
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
const subjectStr = actorAlias.charAt(0).toUpperCase() + actorAlias.slice(1);
let details = (entry.intent.description || entry.intent.originalText).trim();
if (entry.intent.type === "action" && entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
return `${subjectStr} ${details}`;
return details;
}
export class BufferRepository {

View File

@@ -0,0 +1,61 @@
import { Entity } from "@omnia/core";
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
import { BufferEntry, serializeSubjectiveBufferEntry } from "./buffer.js";
/**
* Prompt builder for the Handoff Engine.
* Separates prompt generation, structure, and component breakdowns.
*/
export class HandoffPromptBuilder implements IPromptBuilder<
[Entity, BufferEntry[], Date]
> {
build(entity: Entity, candidates: BufferEntry[], now: Date): PromptBreakdown {
const candidatesList = candidates
.map((entry) => {
const serialized = serializeSubjectiveBufferEntry(entry, entity);
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
})
.join("\n---\n");
const systemPrompt = `
You are the memory Handoff Engine. Your task is to process a list of Cognitive Buffer entries for an entity and select which memories to promote to the Memory Ledger, and which to forget or summarize.
Instructions:
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. physical action and its outcome or trivial actions). Combine them into a single chunk.
2. **Write in the third-person** for the events of other entities. (eg. Alan did that. Sarah did this, etc)
2. **Write in first-person for the events that you yourself did. (eg. I did this, I did that.)
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
4. Discard small body movements like looking around, sighing, etc that do not contextually hold any meaning after it is done.
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
6. **Retain in Cognitive Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the Cognitive Buffer for immediate context. Otherwise, set it to false so it is safely pruned from the Cognitive Buffer.
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
`.trim();
const entityContext = `
Subject Entity ID: ${entity.id}
Current Time: ${now.toISOString()}
`.trim();
const candidatesSection = `Cognitive Buffer Candidates for Handoff:\n${candidatesList}`;
const userContext = `${entityContext}\n\n${candidatesSection}`;
const components: PromptComponent[] = [
{ label: "System Prompt", type: "system", content: systemPrompt },
{ label: "Entity Context", type: "world", content: entityContext },
{
label: "Cognitive Candidates",
type: "input",
content: candidatesSection,
},
];
return {
systemPrompt,
userContext,
components,
};
}
}

View File

@@ -6,7 +6,8 @@ import {
BufferRepository,
} from "./buffer.js";
import { LedgerEntry, LedgerRepository } from "./ledger.js";
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
import { ILLMProvider, IEmbeddingProvider, PromptComponent } from "@omnia/llm";
import { HandoffPromptBuilder } from "./handoff-prompt-builder.js";
export const HandoffChunkSchema = z.object({
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
@@ -33,7 +34,7 @@ export function getMemorySectionLength(
now: Date,
): number {
if (entries.length === 0) {
return `=== RECENT EVENTS ===\n(No recent events recorded.)`.length;
return `=== COGNITIVE BUFFER ===\n(No entries recorded.)`.length;
}
const groupedLines: string[] = [];
@@ -52,7 +53,7 @@ export function getMemorySectionLength(
groupedLines.push(` - ${serialized}`);
}
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`.length;
return `=== COGNITIVE BUFFER ===\n${groupedLines.join("\n")}`.length;
}
function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
@@ -85,7 +86,9 @@ function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
// Check the last N entries
const lastN = bufferEntries.slice(-N);
return lastN.every((e) => e.intent.type === "monologue");
return lastN.every(
(e) => e.intent.type === "monologue" || e.intent.type === "thought",
);
}
function checkAttributeTrigger(entity: Entity): boolean {
@@ -200,53 +203,44 @@ export function splitBufferForHandoff(
/**
* HandoffEngine processes memory handoffs using LLM summarization and DB transactions.
*/
export interface HandoffRunResult {
success: boolean;
systemPrompt?: string;
userContext?: string;
promptComponents?: PromptComponent[];
response?: unknown;
}
export class HandoffEngine {
public lastResult: HandoffRunResult | null = null;
private promptBuilder: HandoffPromptBuilder;
constructor(
private llmProvider: ILLMProvider,
private embedProvider: IEmbeddingProvider,
private bufferRepo: BufferRepository,
private ledgerRepo: LedgerRepository,
) {}
) {
this.promptBuilder = new HandoffPromptBuilder();
}
async runHandoff(
entity: Entity,
bufferEntries: BufferEntry[],
now: Date,
): Promise<boolean> {
this.lastResult = null;
const { candidates } = splitBufferForHandoff(bufferEntries, now);
if (candidates.length === 0) {
return false;
}
const candidatesList = candidates
.map((entry) => {
const serialized = serializeSubjectiveBufferEntry(entry, entity);
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
})
.join("\n---\n");
const systemPrompt = `
You are the memory Handoff Engine. Your task is to process a list of recent working memory buffer entries for an entity and select which memories to promote to the long-term Ledger, and which to forget or summarize.
Instructions:
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. a full back-and-forth conversation or a single physical action and its outcome). Combine them into a single summary chunk.
2. **Write in the third-person** for the "content" of each chunk (e.g. "John asked Mary for the key, and Mary reluctantly handed it over").
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
6. **Retain in Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the working memory buffer for immediate context. Otherwise, set it to false so it is safely pruned from the buffer.
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
`.trim();
const userContext = `
Subject Entity ID: ${entity.id}
Current Time: ${now.toISOString()}
Working Memory Candidates for Handoff:
${candidatesList}
`.trim();
const { systemPrompt, userContext, components } = this.promptBuilder.build(
entity,
candidates,
now,
);
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
@@ -255,13 +249,27 @@ ${candidatesList}
});
if (!response.success || !response.data) {
this.lastResult = {
success: false,
systemPrompt,
userContext,
promptComponents: components,
};
return false;
}
this.lastResult = {
success: true,
systemPrompt,
userContext,
promptComponents: components,
response: response.data,
};
const result = response.data;
const db = (
this.bufferRepo as unknown as {
db: { transaction: (fn: () => void) => void };
db: { transaction: (fn: () => void) => () => void };
}
).db;

View File

@@ -1,20 +1,20 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { describe, test, expect } from "vitest";
import { Entity } from "@omnia/core";
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
import {
BufferEntry,
BufferRepository,
LedgerRepository,
checkHandoffTrigger,
splitBufferForHandoff,
HandoffEngine,
splitBufferForHandoff,
checkHandoffTrigger,
} from "@omnia/memory";
describe("Memory Handoff Tests (Tier 1)", () => {
const now = new Date("2026-07-07T12:00:00.000Z");
const now = new Date("2026-07-09T08:00:00.000Z");
test("splitBufferForHandoff correctly splits based on watermark and fresh buckets", () => {
describe("Memory Handoff Tests (Tier 1)", () => {
test("splitBufferForHandoff identifies candidate entries based on recency", () => {
const entries: BufferEntry[] = [];
// Add 12 older entries (older than 30 minutes)
@@ -30,8 +30,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: "dialogue",
originalText: `Old event ${i}`,
description: `does old thing ${i}`,
content: `entity@alice[I] do old thing ${i}`,
actorId: "alice",
targetIds: ["bob"],
},
@@ -54,8 +53,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: "dialogue",
originalText: `Fresh event ${idx}`,
description: `does fresh thing ${idx}`,
content: `entity@alice[I] do fresh thing ${idx}`,
actorId: "alice",
targetIds: ["bob"],
},
@@ -84,8 +82,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: "dialogue",
originalText: "hello",
description: "says hello",
content: "entity@alice[I] say hello",
actorId: "alice",
targetIds: [],
},
@@ -100,8 +97,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
locationId: "room-2",
intent: {
type: "monologue",
originalText: "think",
description: "thinks",
content: "entity@alice[I] think",
actorId: "alice",
targetIds: [],
},
@@ -138,8 +134,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: i % 2 === 0 ? "dialogue" : "action",
originalText: `Event ${i}`,
description: `does thing ${i}`,
content: `entity@alice[I] do thing ${i}`,
actorId: "alice",
targetIds: ["bob"],
},

View File

@@ -1,24 +1,14 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { describe, test, expect } from "vitest";
import { Entity, SQLiteRepository } from "@omnia/core";
import { Intent } from "@omnia/intent";
import {
BufferEntry,
BufferRepository,
serializeSubjectiveBufferEntry,
resolveAlias,
} from "@omnia/memory";
describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
test("resolveAlias correctly handles self and fallbacks", () => {
const viewer = new Entity("alice");
viewer.aliases.set("bob", "the hooded figure");
expect(resolveAlias(viewer, "alice")).toBe("you");
expect(resolveAlias(viewer, "bob")).toBe("the hooded figure");
expect(resolveAlias(viewer, "charlie")).toBe("an unfamiliar figure");
});
test("serializes dialogue intent substituting target/actor aliases", () => {
const viewer = new Entity("alice");
viewer.aliases.set("bob", "the hooded figure");
@@ -31,9 +21,8 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: "dialogue",
originalText: '"Hello there," Bob said to Charlie.',
description: "says, 'Hello there' to the bartender",
selfDescription: "You say, 'Hello there' to the bartender.",
content:
"entity@bob[I] say 'Hello there' to entity@charlie[the bartender]",
actorId: "bob",
targetIds: ["charlie"],
modifiers: [],
@@ -42,7 +31,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe(
"The hooded figure says, 'Hello there' to the bartender",
"The hooded figure says 'Hello there' to the bartender",
);
});
@@ -57,9 +46,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: "action",
originalText: "Bob tried to break the latch.",
description: "attempts to break the lock latch",
selfDescription: "You attempt to break the lock latch.",
content: "entity@bob[I] attempt to break the lock latch",
actorId: "bob",
targetIds: [],
modifiers: [],
@@ -86,9 +73,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: "action",
originalText: "I opened the window.",
description: "open the window",
selfDescription: "You open the window.",
content: "entity@alice[I] open the window",
actorId: "alice",
targetIds: [],
modifiers: [],
@@ -96,7 +81,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
expect(resultSelf).toBe("You open the window.");
expect(resultSelf).toBe("I open the window");
const entryUnfamiliar: BufferEntry = {
id: "entry-unfamiliar",
@@ -105,9 +90,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
locationId: "room-1",
intent: {
type: "action",
originalText: "Someone knocked.",
description: "knocks on the door",
selfDescription: "You knock on the door.",
content: "entity@stranger-1[I] knock on the door",
actorId: "stranger-1",
targetIds: [],
modifiers: [],
@@ -136,9 +119,7 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
const intent: Intent = {
type: "action",
originalText: "Alice picked up a stick.",
description: "Alice gathers a stick",
selfDescription: "You gather a stick.",
content: "entity@alice[I] gather a stick",
actorId: "alice",
targetIds: [],
modifiers: [],
@@ -196,8 +177,7 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
locationId: "forest",
intent: {
type: "action",
originalText: "Alice sneezed.",
description: "Alice sneezes",
content: "entity@alice[I] sneeze",
actorId: "alice",
targetIds: [],
},

View File

@@ -8,6 +8,7 @@
"references": [
{ "path": "../core" },
{ "path": "../intent" },
{ "path": "../llm" }
{ "path": "../llm" },
{ "path": "../voice" }
]
}

View File

@@ -145,8 +145,14 @@ export class ScenarioLoader {
timestamp: mem.timestamp,
locationId: mem.locationId,
intent: {
...mem.intent,
selfDescription: mem.intent.selfDescription ?? "",
type: mem.intent.type,
content:
mem.intent.content ||
mem.intent.description ||
mem.intent.originalText ||
"",
actorId: mem.intent.actorId,
targetIds: mem.intent.targetIds,
modifiers: mem.intent.modifiers ?? [],
},
outcome: mem.outcome,

View File

@@ -30,9 +30,10 @@ export const ScenarioMemoryEntrySchema = z.object({
timestamp: z.string(), // ISO string
locationId: z.string().nullable(),
intent: z.object({
type: z.enum(["dialogue", "action", "monologue"]),
originalText: z.string(),
description: z.string(),
type: z.enum(["dialogue", "action", "monologue", "thought"]),
content: z.string().optional(),
originalText: z.string().optional(),
description: z.string().optional(),
selfDescription: z.string().optional(),
actorId: z.string(),
targetIds: z.array(z.string()),

View File

@@ -139,7 +139,7 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
expect(memories[0].id).toBe("mem-seed-1");
expect(memories[0].timestamp).toBe("2026-07-09T07:55:00.000Z");
expect(memories[0].locationId).toBe("lobby");
expect(memories[0].intent.description).toBe("entered the house");
expect(memories[0].intent.content).toBe("entered the house");
db.close();
});

Some files were not shown because too many files have changed in this diff Show More