mirror of
https://github.com/sortedcord/omnia.git
synced 2026-09-05 05:43:47 +05:30
Compare commits
9 Commits
fix/handof
...
8204e9c18e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8204e9c18e | |||
|
|
5fb0d93a52 | ||
| e09b603d14 | |||
| affee4a733 | |||
| 814f216ea7 | |||
| 7f74617077 | |||
| 264a5ea0fe | |||
| a8b2d425a1 | |||
|
|
82fb3c34f7 |
21
.dockerignore
Normal file
21
.dockerignore
Normal 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
65
.github/workflows/build-image.yml
vendored
Normal 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
|
||||
65
.rsyncignore
Normal file
65
.rsyncignore
Normal file
@@ -0,0 +1,65 @@
|
||||
# Git metadata
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Dependencies and package managers
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.pnp
|
||||
.pnp.*
|
||||
.yarn/
|
||||
.yarn-cache/
|
||||
|
||||
# Build and generated output
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
coverage/
|
||||
.next/
|
||||
.astro/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
.turbo/
|
||||
.cache/
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Environment and local config
|
||||
.env
|
||||
.env*
|
||||
*.local
|
||||
|
||||
# OS/editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.sln
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
|
||||
# Databases and local data
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
omnia.db
|
||||
|
||||
# Local notes and generated artifacts
|
||||
__local_notes/
|
||||
59
apps/gui/Dockerfile
Normal file
59
apps/gui/Dockerfile
Normal 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"]
|
||||
18
apps/gui/Dockerfile.dev
Normal file
18
apps/gui/Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-slim
|
||||
ENV PNPM_HOME="/pnpm" \
|
||||
PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install native build tools if required by dependencies
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -qq -y --no-install-recommends \
|
||||
python3 make g++ && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Expose the development port and HMR port if needed
|
||||
EXPOSE 3000
|
||||
|
||||
# We run a shell command or script to handle mounting sync + pnpm install fallback
|
||||
CMD ["sh", "-c", "pnpm install && pnpm build && pnpm --filter @omnia/gui dev --turbopack"]
|
||||
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
|
||||
"@omnia/memory",
|
||||
"@omnia/spatial",
|
||||
"@omnia/scenario",
|
||||
"@omnia/runtime",
|
||||
],
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
allowedDevOrigins: ["192.168.0.18", "localhost", "127.0.0.1"],
|
||||
|
||||
@@ -11,14 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@omnia/runtime": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
|
||||
BIN
apps/gui/public/calendar_logo.png
Normal file
BIN
apps/gui/public/calendar_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 607 B |
BIN
apps/gui/public/clock_day_icon.png
Normal file
BIN
apps/gui/public/clock_day_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/gui/public/clock_night_icon.png
Normal file
BIN
apps/gui/public/clock_night_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/gui/public/map_pointer_icon.png
Normal file
BIN
apps/gui/public/map_pointer_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 463 B |
@@ -271,6 +271,12 @@ export async function createProviderInstance(
|
||||
);
|
||||
}
|
||||
|
||||
export async function duplicateProviderInstance(
|
||||
id: string,
|
||||
): Promise<ModelProviderInstance | null> {
|
||||
return ProviderManager.duplicate(id);
|
||||
}
|
||||
|
||||
export async function deleteProviderInstance(id: string): Promise<void> {
|
||||
ProviderManager.delete(id);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ConfigView() {
|
||||
Configuration
|
||||
</h1>
|
||||
<h2 className="mb-3 text-headline-md text-foreground">
|
||||
Manage Model Instances
|
||||
Manage Model Providers
|
||||
</h2>
|
||||
{config === null && loading && (
|
||||
<p className="text-body-md text-muted-foreground">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
setActiveProviderInstance,
|
||||
regenerateEmbeddings,
|
||||
deleteProviderInstance,
|
||||
duplicateProviderInstance,
|
||||
fetchAvailableModels,
|
||||
fetchAvailableModelsForInstance,
|
||||
} from "@/app/actions";
|
||||
@@ -338,6 +339,23 @@ 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 flex min-h-[600px] flex-col">
|
||||
{error && (
|
||||
@@ -646,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}>
|
||||
|
||||
@@ -25,15 +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();
|
||||
@@ -192,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-[1024px] 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">
|
||||
@@ -237,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" />
|
||||
@@ -264,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"
|
||||
@@ -310,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
|
||||
@@ -331,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" />
|
||||
@@ -345,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
|
||||
@@ -382,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>
|
||||
@@ -412,28 +405,37 @@ export function HomeView() {
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[800px] sm:max-w-[800px] h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
|
||||
<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 — {entry.entityName}</span>
|
||||
@@ -128,7 +128,13 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
|
||||
</h3>
|
||||
{chunks.map(
|
||||
(
|
||||
chunk: { content: string; importance: number },
|
||||
chunk: {
|
||||
content: string;
|
||||
importance: number;
|
||||
quotes?: string[];
|
||||
retainInBuffer?: boolean;
|
||||
involvedEntityIds?: string[];
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<div
|
||||
@@ -240,7 +246,7 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
|
||||
<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-[500px]">
|
||||
<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."}
|
||||
|
||||
188
apps/gui/src/components/play/InteractDock.tsx
Normal file
188
apps/gui/src/components/play/InteractDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
"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 { 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,
|
||||
@@ -26,19 +26,14 @@ function IntentTag({
|
||||
playerId: string;
|
||||
entities: SimSnapshot["entities"];
|
||||
}) {
|
||||
const labels: Record<string, string> = {
|
||||
monologue: "thought",
|
||||
thought: "thought",
|
||||
dialogue: "dialogue",
|
||||
action: "action",
|
||||
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 label = labels[intent.type] || intent.type;
|
||||
|
||||
let outcome = "";
|
||||
if (intent.type === "action") {
|
||||
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
|
||||
}
|
||||
const icon = icons[intent.type] || null;
|
||||
|
||||
const viewerAliasesMap = new Map<string, string>();
|
||||
if (entities) {
|
||||
@@ -69,12 +64,28 @@ function IntentTag({
|
||||
</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">
|
||||
[{label}] “{textToDisplay}”{modifiersStr}
|
||||
{outcome}
|
||||
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
|
||||
</span>
|
||||
<>
|
||||
<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}>
|
||||
“{textToDisplay}”{modifiersStr}
|
||||
{invalidActionReason}
|
||||
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,38 +123,40 @@ function LogEntryCard({
|
||||
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} · {formatSimTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onShowPrompt(entry)}
|
||||
title="View Raw Prompts & Token Usage"
|
||||
>
|
||||
☰
|
||||
</Button>
|
||||
<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} · {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="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">
|
||||
<div className={cn("mt-3 ms-3")}>
|
||||
{entry.intents.map((intent, i) => (
|
||||
<IntentTag
|
||||
key={i}
|
||||
@@ -168,6 +181,9 @@ interface InteractViewProps {
|
||||
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({
|
||||
@@ -180,16 +196,17 @@ export function InteractView({
|
||||
onShowPrompt,
|
||||
onShowHandoff,
|
||||
logEndRef,
|
||||
onPauseRequested,
|
||||
onResumeRequested,
|
||||
onStopRequested,
|
||||
}: InteractViewProps) {
|
||||
const router = useRouter();
|
||||
|
||||
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-[800px] mx-auto pb-12">
|
||||
<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 (
|
||||
@@ -243,54 +260,16 @@ export function InteractView({
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Sticky Chat / Interaction Input Footer */}
|
||||
<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={onSubmitAction} 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>
|
||||
<InteractDock
|
||||
snapshot={snapshot}
|
||||
loading={loading}
|
||||
playerInput={playerInput}
|
||||
setPlayerInput={setPlayerInput}
|
||||
onSubmitAction={onSubmitAction}
|
||||
onPauseRequested={onPauseRequested}
|
||||
onResumeRequested={onResumeRequested}
|
||||
onStopRequested={onStopRequested}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,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">
|
||||
@@ -390,6 +353,14 @@ export function PlayView() {
|
||||
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} />
|
||||
|
||||
@@ -52,21 +52,26 @@ export function PromptAnalyzer({
|
||||
maxContext > 0 ? (inputTokens / maxContext) * 100 : 0;
|
||||
const isAbsolute = maxContext > 0 && usagePctOfContext >= 20;
|
||||
|
||||
const getColorClass = (type: string) => {
|
||||
switch (type) {
|
||||
case "system":
|
||||
return "bg-blue-500";
|
||||
case "world":
|
||||
return "bg-emerald-500";
|
||||
case "events":
|
||||
return "bg-purple-500";
|
||||
case "memories":
|
||||
return "bg-pink-500";
|
||||
case "input":
|
||||
return "bg-amber-500";
|
||||
default:
|
||||
return "bg-slate-500";
|
||||
}
|
||||
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 (
|
||||
@@ -106,7 +111,7 @@ export function PromptAnalyzer({
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${getColorClass(item.type)}`}
|
||||
className={`h-full transition-all duration-300 ${getColorClass(idx)}`}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
|
||||
/>
|
||||
@@ -129,7 +134,7 @@ export function PromptAnalyzer({
|
||||
<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(item.type)}`}
|
||||
className={`inline-block w-2.5 h-2.5 rounded-sm ${getColorClass(idx)}`}
|
||||
/>
|
||||
<span>{item.label}:</span>
|
||||
<span className="text-muted-foreground font-normal">
|
||||
@@ -139,7 +144,7 @@ export function PromptAnalyzer({
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-[300px] overflow-y-auto">
|
||||
<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>
|
||||
@@ -161,7 +166,7 @@ export function PromptAnalyzer({
|
||||
)}
|
||||
</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-[250px] overflow-y-auto">
|
||||
<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>
|
||||
|
||||
@@ -136,7 +136,7 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
}
|
||||
modelName={validatorCall.usage?.modelName}
|
||||
providerInstanceName={validatorCall.usage?.providerInstanceName}
|
||||
outputLabel={`LLM Output (Validation for: "${validatorCall.intentContent}")`}
|
||||
outputLabel={`LLM Output`}
|
||||
outputText={JSON.stringify(validatorCall.response, null, 2)}
|
||||
outputTokens={validatorCall.usage?.outputTokens}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,106 +1,11 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
content: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
minutesToAdvance?: number;
|
||||
}
|
||||
|
||||
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;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HandoffResult {
|
||||
chunks: {
|
||||
content: string;
|
||||
importance: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
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;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
isAgent: boolean;
|
||||
aliases?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface WaitingContext {
|
||||
entityId: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
}
|
||||
|
||||
export interface SimSnapshot {
|
||||
id: string;
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
entities: EntityInfo[];
|
||||
log: LogEntry[];
|
||||
entityIndex: number;
|
||||
waitingEntity?: WaitingContext;
|
||||
error?: string;
|
||||
}
|
||||
export type {
|
||||
EntityInfo,
|
||||
HandoffResult,
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
PromptBreakdown,
|
||||
PromptComponent,
|
||||
SimSnapshot,
|
||||
ValidatorCall,
|
||||
WaitingContext,
|
||||
} from "@omnia/runtime";
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import type { SimSession } from "./types";
|
||||
|
||||
/**
|
||||
* Runs the HandoffEngine for every agent entity that has accumulated enough
|
||||
* 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);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const handoffEngine = new HandoffEngine(
|
||||
session.handoffProvider,
|
||||
session.embeddingProvider,
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
);
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const entity of entities) {
|
||||
if (!entity.isAgent) continue;
|
||||
|
||||
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
|
||||
const maxContext =
|
||||
session.handoffProvider.maxContext !== undefined
|
||||
? session.handoffProvider.maxContext
|
||||
: 32768;
|
||||
|
||||
const trigger = checkHandoffTrigger(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
maxContext,
|
||||
);
|
||||
if (trigger !== "none") {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For every agent that shares a location with another entity they haven't
|
||||
* previously encountered, generates a first-person alias description and
|
||||
* persists it on the viewing entity.
|
||||
*/
|
||||
export async function runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const viewer of entities) {
|
||||
if (!viewer.isAgent) continue;
|
||||
if (!viewer.locationId) continue;
|
||||
|
||||
for (const target of entities) {
|
||||
if (viewer.id === target.id) continue;
|
||||
if (
|
||||
target.locationId === viewer.locationId &&
|
||||
!viewer.aliases.has(target.id)
|
||||
) {
|
||||
const alias = await session.aliasGenerator.generate(viewer, target);
|
||||
viewer.aliases.set(target.id, alias);
|
||||
session.coreRepo.saveEntity(viewer, worldState.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Load .env from monorepo root or apps/gui/
|
||||
const cwd = process.cwd();
|
||||
const envCandidates = [
|
||||
path.resolve(cwd, ".env"),
|
||||
path.resolve(cwd, "../../.env"),
|
||||
];
|
||||
for (const c of envCandidates) {
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
|
||||
dotenv.config({ path: c });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
/**
|
||||
* Barrel entry point for the simulation module.
|
||||
*
|
||||
* Consumers import from "@/lib/simulation" exactly as before — no import
|
||||
* paths need to change anywhere in the codebase.
|
||||
*/
|
||||
import { SimulationManager } from "./simulation-manager";
|
||||
import { RuntimeService } from "@omnia/runtime";
|
||||
|
||||
export const simulationManager = new SimulationManager();
|
||||
export const simulationManager = new RuntimeService();
|
||||
|
||||
export type {
|
||||
SimSnapshot,
|
||||
@@ -14,4 +8,4 @@ export type {
|
||||
LogEntry,
|
||||
IntentInfo,
|
||||
WaitingContext,
|
||||
} from "../simulation-types";
|
||||
} from "@omnia/runtime";
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import {
|
||||
MockLLMProvider,
|
||||
MockEmbeddingProvider,
|
||||
ProviderManager,
|
||||
buildLLMProvider,
|
||||
buildEmbeddingProvider,
|
||||
} from "@omnia/llm";
|
||||
import type {
|
||||
ILLMProvider,
|
||||
IEmbeddingProvider,
|
||||
ModelProviderInstance,
|
||||
} from "@omnia/llm";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ResolvedProviders {
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
}
|
||||
|
||||
export interface ProviderResolverOptions {
|
||||
/**
|
||||
* Pre-resolved generative instance to fall back to when ProviderManager has
|
||||
* no active generative provider (e.g. when the caller already validated a
|
||||
* specific provider during session creation).
|
||||
*/
|
||||
fallbackInstance?: ModelProviderInstance | null;
|
||||
/**
|
||||
* When true, throws an Error if no provider can be resolved for a task.
|
||||
* When false (default), falls back silently to MockLLMProvider / MockEmbeddingProvider.
|
||||
*/
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves all six LLM + embedding providers needed for a simulation session.
|
||||
*
|
||||
* Resolution order for each generative task:
|
||||
* 1. Task-specific mapping from ProviderManager (via `mappings[task]`)
|
||||
* 2. ProviderManager active generative instance
|
||||
* 3. `fallbackInstance` (if supplied)
|
||||
* 4. GOOGLE_API_KEY env var → auto-creates a temporary GeminiProvider
|
||||
* 5. Throws (if `required`) or returns MockLLMProvider
|
||||
*/
|
||||
export function resolveProviders(
|
||||
mappings: Record<string, string>,
|
||||
options: ProviderResolverOptions = {},
|
||||
): ResolvedProviders {
|
||||
const { fallbackInstance = null, required = false } = options;
|
||||
const list = ProviderManager.list();
|
||||
const activeGenerative =
|
||||
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
|
||||
|
||||
const resolveGenerative = (task: string): ILLMProvider => {
|
||||
const mappedId = mappings[task];
|
||||
let inst: ModelProviderInstance | null = mappedId
|
||||
? (list.find((p) => p.id === mappedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!inst || inst.type !== "generative") {
|
||||
inst = activeGenerative;
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create(
|
||||
"Default (Env)",
|
||||
"google-genai",
|
||||
envKey,
|
||||
undefined,
|
||||
"generative",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
if (required) {
|
||||
throw new Error(
|
||||
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
|
||||
);
|
||||
}
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
|
||||
return buildLLMProvider(inst);
|
||||
};
|
||||
|
||||
const resolveEmbedding = (): IEmbeddingProvider => {
|
||||
const mappedId = mappings["embeddings"];
|
||||
let inst: ModelProviderInstance | null = mappedId
|
||||
? (list.find((p) => p.id === mappedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!inst || inst.type !== "embedding") {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create(
|
||||
"Default Embed (Env)",
|
||||
"google-genai",
|
||||
envKey,
|
||||
"gemini-embedding-001",
|
||||
"embedding",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
if (required) {
|
||||
throw new Error(
|
||||
`No active Embedding Provider Instance found. Please configure an embedding key in Settings first.`,
|
||||
);
|
||||
}
|
||||
return new MockEmbeddingProvider(undefined);
|
||||
}
|
||||
|
||||
return buildEmbeddingProvider(inst);
|
||||
};
|
||||
|
||||
return {
|
||||
actorProvider: resolveGenerative("actor-prose"),
|
||||
validatorProvider: resolveGenerative("llm-validator"),
|
||||
decoderProvider: resolveGenerative("intent-decoder"),
|
||||
timedeltaProvider: resolveGenerative("timedelta"),
|
||||
handoffProvider: resolveGenerative("handoff"),
|
||||
embeddingProvider: resolveEmbedding(),
|
||||
};
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import type { SimSession, SavedState } from "./types";
|
||||
import type { SimSnapshot } from "../simulation-types";
|
||||
|
||||
export const DATA_DIR = path.resolve(process.cwd(), "data");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level read/write helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function loadSessionState(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
): SavedState | null {
|
||||
try {
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)`,
|
||||
).run();
|
||||
const row = db
|
||||
.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`)
|
||||
.get(id) as { state_json: string } | undefined;
|
||||
return row ? (JSON.parse(row.state_json) as SavedState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(session: SimSession): void {
|
||||
const state: SavedState = {
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
entities: session.entities,
|
||||
playerEntityId: session.playerEntityId,
|
||||
entityIndex: session.entityIndex,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
waitingEntity: session.waitingEntity,
|
||||
aliasDoneForTurn: session.aliasDoneForTurn,
|
||||
log: session.log,
|
||||
providerMappings: session.providerMappings,
|
||||
};
|
||||
|
||||
session.db
|
||||
.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS gui_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
session.db
|
||||
.prepare(
|
||||
`INSERT INTO gui_meta (id, state_json)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
|
||||
)
|
||||
.run(session.worldInstanceId, JSON.stringify(state));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session file management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function deleteSessionFile(id: string): void {
|
||||
const dbPath = path.join(DATA_DIR, `${id}.db`);
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete session file ${dbPath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all saved simulation snapshots by scanning the data directory.
|
||||
* Active in-memory sessions are snapshotted via the provided callback;
|
||||
* inactive ones are read directly from their `.db` files.
|
||||
*/
|
||||
export function listSavedSessions(
|
||||
activeSessions: Map<string, SimSession>,
|
||||
snapshotFn: (session: SimSession) => SimSnapshot,
|
||||
): SimSnapshot[] {
|
||||
if (!fs.existsSync(DATA_DIR)) return [];
|
||||
|
||||
const snapshots: SimSnapshot[] = [];
|
||||
const files = fs
|
||||
.readdirSync(DATA_DIR)
|
||||
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
for (const file of files) {
|
||||
const id = file.replace(".db", "");
|
||||
const dbPath = path.join(DATA_DIR, file);
|
||||
|
||||
const active = activeSessions.get(id);
|
||||
if (active) {
|
||||
snapshots.push(snapshotFn(active));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
db.close();
|
||||
|
||||
if (state) {
|
||||
snapshots.push({
|
||||
id,
|
||||
status: state.status,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
entities: state.entities || [],
|
||||
log: state.log || [],
|
||||
entityIndex: state.entityIndex,
|
||||
waitingEntity: state.waitingEntity,
|
||||
error: state.error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* skip corrupt / in-use db files */
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots.sort((a, b) => {
|
||||
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
|
||||
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
|
||||
return tsB - tsA;
|
||||
});
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
import "./env"; // Must be first — loads .env before any code reads process.env
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { SQLiteRepository } from "@omnia/core";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import { ProviderManager, buildEmbeddingProvider } from "@omnia/llm";
|
||||
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
import type { SimSnapshot } from "../simulation-types";
|
||||
import type { SimSession, EntityInfo } from "./types";
|
||||
import { resolveProviders } from "./provider-resolver";
|
||||
import {
|
||||
DATA_DIR,
|
||||
loadSessionState,
|
||||
saveSession,
|
||||
listSavedSessions,
|
||||
deleteSessionFile,
|
||||
} from "./session-store";
|
||||
import {
|
||||
preparePlayerTurn,
|
||||
processNpcTurn,
|
||||
executePlayerAction,
|
||||
} from "./turn-executor";
|
||||
import { runAliasResolution, runHandoffResolution } from "./alias-handoff";
|
||||
|
||||
export class SimulationManager {
|
||||
private sessions = new Map<string, SimSession>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async create(
|
||||
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.
|
||||
let activeInstance: ModelProviderInstance | null = providerInstanceId
|
||||
? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
|
||||
: ProviderManager.getActive("generative");
|
||||
|
||||
if (!activeInstance) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
activeInstance = ProviderManager.create(
|
||||
"Default (Env)",
|
||||
"google-genai",
|
||||
envKey,
|
||||
undefined,
|
||||
"generative",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!activeInstance) {
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error:
|
||||
"No active LLM Provider Instance found. Please configure a key in Settings first.",
|
||||
};
|
||||
}
|
||||
|
||||
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
|
||||
const id = `sim-${Date.now()}`;
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const dbPath = path.join(DATA_DIR, `${id}.db`);
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
const loader = new ScenarioLoader(coreRepo, bufferRepo);
|
||||
|
||||
const worldInstanceId = id;
|
||||
await loader.initializeWorld(scenarioJson, worldInstanceId);
|
||||
|
||||
const worldState = coreRepo.loadWorldState(worldInstanceId);
|
||||
if (!worldState) {
|
||||
db.close();
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error: "Failed to load world state after initialization.",
|
||||
};
|
||||
}
|
||||
|
||||
// Build entity list
|
||||
const rawEntities = Array.from(worldState.entities.values());
|
||||
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
|
||||
id: e.id,
|
||||
name: (e.attributes.get("name")?.getValue() as string) || e.id,
|
||||
isPlayer: false,
|
||||
isAgent: e.isAgent,
|
||||
}));
|
||||
|
||||
// Resolve player entity (exact match → name match → fuzzy)
|
||||
let playerEntityId: string | undefined;
|
||||
if (playEntityName) {
|
||||
let matched = worldState.getEntity(playEntityName);
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
string | undefined;
|
||||
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const ent of rawEntities) {
|
||||
const nameAttr = ent.attributes.get("name")?.getValue() as
|
||||
string | undefined;
|
||||
if (
|
||||
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
|
||||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
|
||||
) {
|
||||
matched = ent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
playerEntityId = matched.id;
|
||||
const info = entityInfos.find((e) => e.id === matched!.id);
|
||||
if (info) info.isPlayer = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mappings = ProviderManager.getMappings();
|
||||
const {
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
} = resolveProviders(mappings, { fallbackInstance: activeInstance });
|
||||
|
||||
const architect = new Architect(
|
||||
{ validator: validatorProvider, timedelta: timedeltaProvider },
|
||||
coreRepo,
|
||||
);
|
||||
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId,
|
||||
scenarioName: customName || scenarioJson.name,
|
||||
scenarioDescription: scenarioJson.description || "",
|
||||
turn: 1,
|
||||
maxTurns: 20,
|
||||
entities: entityInfos,
|
||||
playerEntityId,
|
||||
entityIndex: 0,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: [],
|
||||
status: "running",
|
||||
aliasDoneForTurn: false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async load(id: string): Promise<SimSnapshot | null> {
|
||||
const active = this.sessions.get(id);
|
||||
if (active) return this.snapshot(active);
|
||||
|
||||
const dbPath = path.join(DATA_DIR, `${id}.db`);
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
const state = loadSessionState(db, id);
|
||||
if (!state) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const mappings = state.providerMappings || {};
|
||||
const {
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
} = resolveProviders(mappings, { required: true });
|
||||
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
const architect = new Architect(
|
||||
{ validator: validatorProvider, timedelta: timedeltaProvider },
|
||||
coreRepo,
|
||||
);
|
||||
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
|
||||
|
||||
const session: SimSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId: id,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
entities: state.entities || [],
|
||||
playerEntityId: state.playerEntityId,
|
||||
entityIndex: state.entityIndex,
|
||||
actorProvider,
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: state.log || [],
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
waitingEntity: state.waitingEntity,
|
||||
aliasDoneForTurn: state.aliasDoneForTurn || false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
return this.snapshot(session);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load session ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
deleteSession(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
deleteSessionFile(id);
|
||||
}
|
||||
|
||||
listSavedSessions(): SimSnapshot[] {
|
||||
return listSavedSessions(this.sessions, (s) => this.snapshot(s));
|
||||
}
|
||||
|
||||
getSnapshot(id: string): SimSnapshot | null {
|
||||
const session = this.sessions.get(id);
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async step(id: string): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "running") return this.snapshot(session);
|
||||
|
||||
try {
|
||||
if (session.turn > session.maxTurns) {
|
||||
session.status = "done";
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
// Start of turn: alias + handoff resolution before any entity acts
|
||||
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await runAliasResolution(session);
|
||||
await runHandoffResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
// End of turn: advance to next turn
|
||||
if (session.entityIndex >= session.entities.length) {
|
||||
session.turn++;
|
||||
session.entityIndex = 0;
|
||||
session.aliasDoneForTurn = false;
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
const info = session.entities[session.entityIndex];
|
||||
|
||||
if (!info.isAgent) {
|
||||
session.entityIndex++;
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
if (info.isPlayer) {
|
||||
await preparePlayerTurn(session, info);
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
await processNpcTurn(session, info);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async submitPlayerAction(
|
||||
id: string,
|
||||
prose: string,
|
||||
): Promise<SimSnapshot | null> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "waiting_player") return this.snapshot(session);
|
||||
if (!session.waitingEntity) return this.snapshot(session);
|
||||
|
||||
const ctx = session.waitingEntity;
|
||||
session.waitingEntity = undefined;
|
||||
session.status = "running";
|
||||
|
||||
try {
|
||||
await executePlayerAction(session, ctx, prose);
|
||||
session.entityIndex++;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
|
||||
if (!fs.existsSync(DATA_DIR)) return;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(DATA_DIR)
|
||||
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
|
||||
|
||||
const list = ProviderManager.list();
|
||||
let inst = newProviderInstanceId
|
||||
? (list.find((p) => p.id === newProviderInstanceId) ?? null)
|
||||
: null;
|
||||
if (!inst || inst.type !== "embedding") {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
|
||||
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 = buildEmbeddingProvider(inst);
|
||||
|
||||
for (const file of files) {
|
||||
const dbPath = path.join(DATA_DIR, file);
|
||||
const fileId = file.replace(".db", "");
|
||||
const activeSession = this.sessions.get(fileId);
|
||||
const db = activeSession ? activeSession.db : new Database(dbPath);
|
||||
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(`SELECT id, content FROM ledger_entries`)
|
||||
.all() as { id: string; content: string }[];
|
||||
|
||||
for (const row of rows) {
|
||||
const vector = await embeddingProvider.embed(row.content);
|
||||
const buffer = Buffer.from(new Float32Array(vector).buffer);
|
||||
db.prepare(
|
||||
`UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
|
||||
).run(buffer, row.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to regenerate embeddings for ${file}:`, err);
|
||||
} finally {
|
||||
if (!activeSession) db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
entities: hydratedEntities,
|
||||
log: session.log,
|
||||
entityIndex: session.entityIndex,
|
||||
waitingEntity: session.waitingEntity,
|
||||
error: session.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import {
|
||||
ActorAgent,
|
||||
ActorPromptBuilder,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import type { IActorProseGenerator } from "@omnia/actor";
|
||||
import type { SimSession } from "./types";
|
||||
import type {
|
||||
EntityInfo,
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
WaitingContext,
|
||||
} from "../simulation-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Prose generator that returns a fixed player-supplied string verbatim. */
|
||||
class FixedProseGenerator implements IActorProseGenerator {
|
||||
constructor(private prose: string) {}
|
||||
|
||||
async generate(
|
||||
entityId: string,
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
): Promise<string> {
|
||||
void entityId;
|
||||
void systemPrompt;
|
||||
void userContext;
|
||||
return this.prose;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes every intent produced by an actor turn:
|
||||
* - Validates via Architect
|
||||
* - Appends to actor's own buffer
|
||||
* - Fan-outs to co-located observers for dialogue/action intents
|
||||
*
|
||||
* Extracted to eliminate verbatim duplication between NPC and player paths.
|
||||
*/
|
||||
async function processIntents(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
intents: any[],
|
||||
actorEntityId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
entity: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
worldState: any,
|
||||
session: SimSession,
|
||||
): Promise<{ intentInfos: IntentInfo[]; validatorCalls: ValidatorCall[] }> {
|
||||
const intentInfos: IntentInfo[] = [];
|
||||
const validatorCalls: ValidatorCall[] = [];
|
||||
|
||||
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,
|
||||
content: intent.content,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
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.promptComponents,
|
||||
},
|
||||
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 };
|
||||
}
|
||||
session.bufferRepo.save(actorEntry);
|
||||
|
||||
// Fan-out observable events to co-located entities
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const [, other] of worldState.entities) {
|
||||
if (
|
||||
other.id !== actorEntityId &&
|
||||
other.locationId === entity.locationId
|
||||
) {
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
ts,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { intentInfos, validatorCalls };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported turn functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the prompt for the player entity and sets the session to
|
||||
* `waiting_player` so the next client call can supply the prose.
|
||||
*/
|
||||
export async function preparePlayerTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const promptBuilder = new ActorPromptBuilder(
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(worldState, entity);
|
||||
|
||||
session.waitingEntity = {
|
||||
entityId: info.id,
|
||||
name: info.name,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
};
|
||||
session.status = "waiting_player";
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an autonomous NPC turn: generates prose via ActorAgent, validates
|
||||
* and persists all intents, and appends a LogEntry to the session.
|
||||
*/
|
||||
export async function processNpcTurn(
|
||||
session: SimSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const actor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
);
|
||||
const result = await actor.act(worldState, entity);
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: info.id,
|
||||
entityName: info.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
rawPrompt: {
|
||||
systemPrompt: result.systemPrompt || "",
|
||||
userContext: result.userContext || "",
|
||||
components: result.promptComponents,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
session.actorProvider.lastCalls &&
|
||||
session.actorProvider.lastCalls.length > 0
|
||||
) {
|
||||
const actorCall =
|
||||
session.actorProvider.lastCalls[
|
||||
session.actorProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.usage = actorCall.usage;
|
||||
}
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const decoderCall =
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the player's turn using the prose they supplied.
|
||||
* Uses a `FixedProseGenerator` so the ActorAgent bypasses its LLM call and
|
||||
* returns the player's text directly.
|
||||
*/
|
||||
export async function executePlayerAction(
|
||||
session: SimSession,
|
||||
ctx: WaitingContext,
|
||||
prose: string,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entity = worldState.getEntity(ctx.entityId);
|
||||
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
|
||||
|
||||
const playerActor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
);
|
||||
|
||||
const result = await playerActor.act(worldState, entity);
|
||||
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: ctx.entityId,
|
||||
entityName: ctx.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
rawPrompt: {
|
||||
systemPrompt: result.systemPrompt || ctx.systemPrompt,
|
||||
userContext: result.userContext || ctx.userContext,
|
||||
components: result.promptComponents,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const call =
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { SQLiteRepository } from "@omnia/core";
|
||||
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
import type { EntityInfo, LogEntry, WaitingContext } from "../simulation-types";
|
||||
|
||||
export type {
|
||||
EntityInfo,
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
SimSnapshot,
|
||||
WaitingContext,
|
||||
} from "../simulation-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted state (written to sqlite gui_meta table as JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SavedState {
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
log: LogEntry[];
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory session (held in SimulationManager.sessions Map)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SimSession {
|
||||
db: Database.Database;
|
||||
dbPath: string;
|
||||
coreRepo: SQLiteRepository;
|
||||
bufferRepo: BufferRepository;
|
||||
ledgerRepo: LedgerRepository;
|
||||
worldInstanceId: string;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
log: LogEntry[];
|
||||
status: "running" | "waiting_player" | "done" | "error";
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
24
docker-compose.dev.yml
Normal file
24
docker-compose.dev.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
omnia-gui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/gui/Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- WATCHPACK_POLLING=true # Useful for certain Linux/LXC filesystem event syncing issues
|
||||
- CHOKIDAR_USEPOLLING=true # Ensures file changes trigger HMR properly through bind mounts
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
volumes:
|
||||
# Mount the entire monorepo code live into the container
|
||||
- .:/app
|
||||
# Anonymous volumes to prevent local node_modules / build caches from overwriting container binaries
|
||||
- /app/node_modules
|
||||
- /app/apps/gui/node_modules
|
||||
- /app/apps/gui/.next
|
||||
- omnia-data:/app/apps/gui/data
|
||||
|
||||
volumes:
|
||||
omnia-data:
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal 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:
|
||||
@@ -28,7 +28,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.13.0",
|
||||
"version": "11.15.1",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -62,6 +62,50 @@ export class ProviderManager {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
|
||||
18
packages/runtime/package.json
Normal file
18
packages/runtime/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@omnia/runtime",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"better-sqlite3": "^12.11.1"
|
||||
}
|
||||
}
|
||||
93
packages/runtime/src/alias-handoff.ts
Normal file
93
packages/runtime/src/alias-handoff.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import type { HandoffResult } from "./snapshot.js";
|
||||
import type { RuntimeSession } from "./session.js";
|
||||
|
||||
function isHandoffResult(value: unknown): value is HandoffResult {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
return Array.isArray((value as { chunks?: unknown }).chunks);
|
||||
}
|
||||
|
||||
export async function runHandoffResolution(
|
||||
session: RuntimeSession,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const handoffEngine = new HandoffEngine(
|
||||
session.handoffProvider,
|
||||
session.embeddingProvider,
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
);
|
||||
|
||||
for (const entity of worldState.entities.values()) {
|
||||
if (!entity.isAgent) continue;
|
||||
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
|
||||
const trigger = checkHandoffTrigger(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
session.handoffProvider.maxContext ?? 32768,
|
||||
);
|
||||
if (trigger === "none") continue;
|
||||
|
||||
const ran = await handoffEngine.runHandoff(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
);
|
||||
if (!ran) continue;
|
||||
|
||||
const lastResult = handoffEngine.lastResult;
|
||||
const lastCall = session.handoffProvider.lastCalls?.at(-1);
|
||||
const entityName =
|
||||
session.entities.find((item) => item.id === entity.id)?.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: isHandoffResult(lastResult?.response)
|
||||
? lastResult.response
|
||||
: isHandoffResult(lastCall?.response)
|
||||
? lastCall.response
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAliasResolution(
|
||||
session: RuntimeSession,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const viewer of entities) {
|
||||
if (!viewer.isAgent || !viewer.locationId) continue;
|
||||
for (const target of entities) {
|
||||
if (
|
||||
viewer.id !== target.id &&
|
||||
target.locationId === viewer.locationId &&
|
||||
!viewer.aliases.has(target.id)
|
||||
) {
|
||||
viewer.aliases.set(
|
||||
target.id,
|
||||
await session.aliasGenerator.generate(viewer, target),
|
||||
);
|
||||
session.coreRepo.saveEntity(viewer, worldState.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/runtime/src/commands.ts
Normal file
16
packages/runtime/src/commands.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface CreateRuntimeCommand {
|
||||
scenarioPath: string;
|
||||
playEntityName?: string;
|
||||
providerInstanceId?: string;
|
||||
customName?: string;
|
||||
}
|
||||
|
||||
export interface SubmitPlayerActionCommand {
|
||||
sessionId: string;
|
||||
prose: string;
|
||||
}
|
||||
|
||||
export interface RenameRuntimeCommand {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
}
|
||||
24
packages/runtime/src/errors.ts
Normal file
24
packages/runtime/src/errors.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export class RuntimeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "RuntimeError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends RuntimeError {
|
||||
constructor(sessionId: string) {
|
||||
super(`Runtime session not found: ${sessionId}`, "SESSION_NOT_FOUND");
|
||||
this.name = "SessionNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderUnavailableError extends RuntimeError {
|
||||
constructor(message: string) {
|
||||
super(message, "PROVIDER_UNAVAILABLE");
|
||||
this.name = "ProviderUnavailableError";
|
||||
}
|
||||
}
|
||||
15
packages/runtime/src/index.ts
Normal file
15
packages/runtime/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export * from "./commands.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./providers.js";
|
||||
export * from "./runtime-service.js";
|
||||
export * from "./session.js";
|
||||
export * from "./snapshot.js";
|
||||
export * from "./persistence/types.js";
|
||||
export * from "./persistence/sqlite-session-store.js";
|
||||
export * from "./testing/runtime-fixtures.js";
|
||||
export {
|
||||
executePlayerAction,
|
||||
preparePlayerTurn,
|
||||
processNpcTurn,
|
||||
} from "./turn-executor.js";
|
||||
export { runAliasResolution, runHandoffResolution } from "./alias-handoff.js";
|
||||
163
packages/runtime/src/persistence/sqlite-session-store.ts
Normal file
163
packages/runtime/src/persistence/sqlite-session-store.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import type { RuntimeSession, SavedSessionState } from "../session.js";
|
||||
import type { RuntimeSnapshot } from "../snapshot.js";
|
||||
import type { SessionStore } from "./types.js";
|
||||
|
||||
const RUNTIME_META = "runtime_meta";
|
||||
const LEGACY_META = "gui_meta";
|
||||
|
||||
export class SQLiteSessionStore implements SessionStore {
|
||||
constructor(
|
||||
readonly dataDir: string = path.resolve(process.cwd(), "data"),
|
||||
) { }
|
||||
|
||||
loadState(db: Database.Database, id: string): SavedSessionState | null {
|
||||
try {
|
||||
this.ensureRuntimeTable(db);
|
||||
let row = this.readRow(db, RUNTIME_META, id);
|
||||
if (!row && this.tableExists(db, LEGACY_META)) {
|
||||
row = this.readRow(db, LEGACY_META, id);
|
||||
if (row) this.writeStateJson(db, id, row.state_json);
|
||||
}
|
||||
return row ? (JSON.parse(row.state_json) as SavedSessionState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
save(session: RuntimeSession): void {
|
||||
const state: SavedSessionState = {
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
entities: session.entities,
|
||||
playerEntityId: session.playerEntityId,
|
||||
entityIndex: session.entityIndex,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
waitingEntity: session.waitingEntity,
|
||||
aliasDoneForTurn: session.aliasDoneForTurn,
|
||||
log: session.log,
|
||||
providerMappings: session.providerMappings,
|
||||
};
|
||||
this.ensureRuntimeTable(session.db);
|
||||
this.writeStateJson(
|
||||
session.db,
|
||||
session.worldInstanceId,
|
||||
JSON.stringify(state),
|
||||
);
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
const dbPath = this.pathFor(id);
|
||||
if (!fs.existsSync(dbPath)) return;
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete session file ${dbPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
list(
|
||||
activeSessions: ReadonlyMap<string, RuntimeSession>,
|
||||
snapshot: (session: RuntimeSession) => RuntimeSnapshot,
|
||||
): RuntimeSnapshot[] {
|
||||
if (!fs.existsSync(this.dataDir)) return [];
|
||||
const snapshots: RuntimeSnapshot[] = [];
|
||||
const files = fs
|
||||
.readdirSync(this.dataDir)
|
||||
.filter((file) => file.startsWith("sim-") && file.endsWith(".db"));
|
||||
|
||||
for (const file of files) {
|
||||
const id = file.slice(0, -3);
|
||||
const active = activeSessions.get(id);
|
||||
if (active) {
|
||||
snapshots.push(snapshot(active));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const db = new Database(this.pathFor(id));
|
||||
const state = this.loadState(db, id);
|
||||
db.close();
|
||||
if (state) {
|
||||
snapshots.push({
|
||||
id,
|
||||
status: state.status,
|
||||
turn: state.turn,
|
||||
maxTurns: state.maxTurns,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
entities: state.entities || [],
|
||||
log: state.log || [],
|
||||
entityIndex: state.entityIndex,
|
||||
waitingEntity: state.waitingEntity,
|
||||
error: state.error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip corrupt or locked session files.
|
||||
}
|
||||
}
|
||||
return snapshots.sort(
|
||||
(a, b) =>
|
||||
(Number.parseInt(b.id.replace("sim-", ""), 10) || 0) -
|
||||
(Number.parseInt(a.id.replace("sim-", ""), 10) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
pathFor(id: string): string {
|
||||
return path.join(this.dataDir, `${id}.db`);
|
||||
}
|
||||
|
||||
private ensureRuntimeTable(db: Database.Database): void {
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS runtime_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
state_json TEXT
|
||||
)`,
|
||||
).run();
|
||||
}
|
||||
|
||||
private tableExists(db: Database.Database, table: string): boolean {
|
||||
return Boolean(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
)
|
||||
.get(table),
|
||||
);
|
||||
}
|
||||
|
||||
private readRow(
|
||||
db: Database.Database,
|
||||
table: typeof RUNTIME_META | typeof LEGACY_META,
|
||||
id: string,
|
||||
): { state_json: string } | undefined {
|
||||
return db
|
||||
.prepare(`SELECT state_json FROM ${table} WHERE id = ?`)
|
||||
.get(id) as { state_json: string } | undefined;
|
||||
}
|
||||
|
||||
private writeStateJson(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
stateJson: string,
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO runtime_meta (id, state_json)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
|
||||
).run(id, stateJson);
|
||||
}
|
||||
}
|
||||
|
||||
export const DATA_DIR = path.resolve(process.cwd(), "data");
|
||||
const defaultStore = new SQLiteSessionStore(DATA_DIR);
|
||||
|
||||
export const loadSessionState = defaultStore.loadState.bind(defaultStore);
|
||||
export const saveSession = defaultStore.save.bind(defaultStore);
|
||||
export const deleteSessionFile = defaultStore.delete.bind(defaultStore);
|
||||
export const listSavedSessions = defaultStore.list.bind(defaultStore);
|
||||
14
packages/runtime/src/persistence/types.ts
Normal file
14
packages/runtime/src/persistence/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { RuntimeSession, SavedSessionState } from "../session.js";
|
||||
import type { RuntimeSnapshot } from "../snapshot.js";
|
||||
|
||||
export interface SessionStore {
|
||||
readonly dataDir: string;
|
||||
loadState(db: Database.Database, id: string): SavedSessionState | null;
|
||||
save(session: RuntimeSession): void;
|
||||
delete(id: string): void;
|
||||
list(
|
||||
activeSessions: ReadonlyMap<string, RuntimeSession>,
|
||||
snapshot: (session: RuntimeSession) => RuntimeSnapshot,
|
||||
): RuntimeSnapshot[];
|
||||
}
|
||||
101
packages/runtime/src/providers.ts
Normal file
101
packages/runtime/src/providers.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
MockLLMProvider,
|
||||
MockEmbeddingProvider,
|
||||
ProviderManager,
|
||||
buildLLMProvider,
|
||||
buildEmbeddingProvider,
|
||||
} from "@omnia/llm";
|
||||
import type {
|
||||
ILLMProvider,
|
||||
IEmbeddingProvider,
|
||||
ModelProviderInstance,
|
||||
} from "@omnia/llm";
|
||||
|
||||
export interface ResolvedProviders {
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
}
|
||||
|
||||
export interface ProviderResolverOptions {
|
||||
fallbackInstance?: ModelProviderInstance | null;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function resolveProviders(
|
||||
mappings: Record<string, string>,
|
||||
options: ProviderResolverOptions = {},
|
||||
): ResolvedProviders {
|
||||
const { fallbackInstance = null, required = false } = options;
|
||||
const list = ProviderManager.list();
|
||||
const activeGenerative =
|
||||
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
|
||||
|
||||
const resolveGenerative = (task: string): ILLMProvider => {
|
||||
const mappedId = mappings[task];
|
||||
let inst: ModelProviderInstance | null = mappedId
|
||||
? (list.find((provider) => provider.id === mappedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!inst || inst.type !== "generative") inst = activeGenerative;
|
||||
if (!inst && process.env.GOOGLE_API_KEY) {
|
||||
inst = ProviderManager.create(
|
||||
"Default (Env)",
|
||||
"google-genai",
|
||||
process.env.GOOGLE_API_KEY,
|
||||
undefined,
|
||||
"generative",
|
||||
);
|
||||
}
|
||||
if (!inst) {
|
||||
if (required) {
|
||||
throw new Error(
|
||||
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
|
||||
);
|
||||
}
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
return buildLLMProvider(inst);
|
||||
};
|
||||
|
||||
const resolveEmbedding = (): IEmbeddingProvider => {
|
||||
const mappedId = mappings.embeddings;
|
||||
let inst: ModelProviderInstance | null = mappedId
|
||||
? (list.find((provider) => provider.id === mappedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!inst || inst.type !== "embedding") {
|
||||
inst = ProviderManager.getActive("embedding");
|
||||
}
|
||||
if (!inst && process.env.GOOGLE_API_KEY) {
|
||||
inst = ProviderManager.create(
|
||||
"Default Embed (Env)",
|
||||
"google-genai",
|
||||
process.env.GOOGLE_API_KEY,
|
||||
"gemini-embedding-001",
|
||||
"embedding",
|
||||
);
|
||||
}
|
||||
if (!inst) {
|
||||
if (required) {
|
||||
throw new Error(
|
||||
"No active Embedding Provider Instance found. Please configure an embedding key in Settings first.",
|
||||
);
|
||||
}
|
||||
return new MockEmbeddingProvider(undefined);
|
||||
}
|
||||
return buildEmbeddingProvider(inst);
|
||||
};
|
||||
|
||||
return {
|
||||
actorProvider: resolveGenerative("actor-prose"),
|
||||
validatorProvider: resolveGenerative("llm-validator"),
|
||||
decoderProvider: resolveGenerative("intent-decoder"),
|
||||
timedeltaProvider: resolveGenerative("timedelta"),
|
||||
handoffProvider: resolveGenerative("handoff"),
|
||||
embeddingProvider: resolveEmbedding(),
|
||||
};
|
||||
}
|
||||
446
packages/runtime/src/runtime-service.ts
Normal file
446
packages/runtime/src/runtime-service.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { SQLiteRepository } from "@omnia/core";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import { ProviderManager, buildEmbeddingProvider } from "@omnia/llm";
|
||||
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
import type { RuntimeSession } from "./session.js";
|
||||
import type { EntityInfo, RuntimeSnapshot } from "./snapshot.js";
|
||||
import { resolveProviders } from "./providers.js";
|
||||
import { SQLiteSessionStore } from "./persistence/sqlite-session-store.js";
|
||||
import type { SessionStore } from "./persistence/types.js";
|
||||
import {
|
||||
preparePlayerTurn,
|
||||
processNpcTurn,
|
||||
executePlayerAction,
|
||||
} from "./turn-executor.js";
|
||||
import { runAliasResolution, runHandoffResolution } from "./alias-handoff.js";
|
||||
|
||||
export interface RuntimeServiceOptions {
|
||||
dataDir?: string;
|
||||
store?: SessionStore;
|
||||
idFactory?: () => string;
|
||||
}
|
||||
|
||||
export class RuntimeService {
|
||||
private readonly sessions = new Map<string, RuntimeSession>();
|
||||
private readonly pending = new Map<string, Promise<unknown>>();
|
||||
private readonly store: SessionStore;
|
||||
private readonly idFactory: () => string;
|
||||
private lastTimestamp = 0;
|
||||
|
||||
constructor(options: RuntimeServiceOptions = {}) {
|
||||
this.store =
|
||||
options.store ??
|
||||
new SQLiteSessionStore(
|
||||
options.dataDir ?? path.resolve(process.cwd(), "data"),
|
||||
);
|
||||
this.idFactory =
|
||||
options.idFactory ??
|
||||
(() => {
|
||||
this.lastTimestamp = Math.max(Date.now(), this.lastTimestamp + 1);
|
||||
return `sim-${this.lastTimestamp}`;
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
scenarioPath: string,
|
||||
playEntityName?: string,
|
||||
providerInstanceId?: string,
|
||||
customName?: string,
|
||||
): Promise<RuntimeSnapshot> {
|
||||
let activeInstance: ModelProviderInstance | null = providerInstanceId
|
||||
? (ProviderManager.list().find((item) => item.id === providerInstanceId) ??
|
||||
null)
|
||||
: ProviderManager.getActive("generative");
|
||||
if (!activeInstance && process.env.GOOGLE_API_KEY) {
|
||||
activeInstance = ProviderManager.create(
|
||||
"Default (Env)",
|
||||
"google-genai",
|
||||
process.env.GOOGLE_API_KEY,
|
||||
undefined,
|
||||
"generative",
|
||||
);
|
||||
}
|
||||
if (!activeInstance) return this.providerErrorSnapshot();
|
||||
|
||||
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
|
||||
const id = this.idFactory();
|
||||
fs.mkdirSync(this.store.dataDir, { recursive: true });
|
||||
const dbPath = path.join(this.store.dataDir, `${id}.db`);
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
await new ScenarioLoader(coreRepo, bufferRepo).initializeWorld(
|
||||
scenarioJson,
|
||||
id,
|
||||
);
|
||||
|
||||
const worldState = coreRepo.loadWorldState(id);
|
||||
if (!worldState) {
|
||||
db.close();
|
||||
return this.errorSnapshot("Failed to load world state after initialization.");
|
||||
}
|
||||
const rawEntities = Array.from(worldState.entities.values());
|
||||
const entities: EntityInfo[] = rawEntities.map((entity) => ({
|
||||
id: entity.id,
|
||||
name:
|
||||
(entity.attributes.get("name")?.getValue() as string | undefined) ??
|
||||
entity.id,
|
||||
isPlayer: false,
|
||||
isAgent: entity.isAgent,
|
||||
}));
|
||||
const playerEntityId = this.resolvePlayerEntity(
|
||||
rawEntities,
|
||||
entities,
|
||||
playEntityName,
|
||||
);
|
||||
const mappings = ProviderManager.getMappings();
|
||||
const providers = resolveProviders(mappings, {
|
||||
fallbackInstance: activeInstance,
|
||||
});
|
||||
const session: RuntimeSession = {
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId: id,
|
||||
scenarioName: customName || scenarioJson.name,
|
||||
scenarioDescription: scenarioJson.description || "",
|
||||
turn: 1,
|
||||
maxTurns: 20,
|
||||
entities,
|
||||
playerEntityId,
|
||||
entityIndex: 0,
|
||||
...providers,
|
||||
architect: new Architect(
|
||||
{
|
||||
validator: providers.validatorProvider,
|
||||
timedelta: providers.timedeltaProvider,
|
||||
},
|
||||
coreRepo,
|
||||
),
|
||||
aliasGenerator: new AliasDeltaGenerator(providers.actorProvider),
|
||||
log: [],
|
||||
status: "running",
|
||||
aliasDoneForTurn: false,
|
||||
providerMappings: mappings,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
this.store.save(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
async load(id: string): Promise<RuntimeSnapshot | null> {
|
||||
return this.exclusive(id, async () => {
|
||||
const active = this.sessions.get(id);
|
||||
if (active) return this.snapshot(active);
|
||||
const dbPath = path.join(this.store.dataDir, `${id}.db`);
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
const state = this.store.loadState(db, id);
|
||||
if (!state) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
const providers = resolveProviders(state.providerMappings || {}, {
|
||||
required: true,
|
||||
});
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
const session: RuntimeSession = {
|
||||
...state,
|
||||
db,
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId: id,
|
||||
...providers,
|
||||
architect: new Architect(
|
||||
{
|
||||
validator: providers.validatorProvider,
|
||||
timedelta: providers.timedeltaProvider,
|
||||
},
|
||||
coreRepo,
|
||||
),
|
||||
aliasGenerator: new AliasDeltaGenerator(providers.actorProvider),
|
||||
entities: state.entities || [],
|
||||
log: state.log || [],
|
||||
aliasDoneForTurn: state.aliasDoneForTurn || false,
|
||||
providerMappings: state.providerMappings || {},
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
return this.snapshot(session);
|
||||
} catch (error) {
|
||||
if (db?.open) db.close();
|
||||
console.error(`Failed to load session ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) session.db.close();
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
|
||||
deleteSession(id: string): void {
|
||||
this.close(id);
|
||||
this.store.delete(id);
|
||||
}
|
||||
|
||||
listSavedSessions(): RuntimeSnapshot[] {
|
||||
return this.store.list(this.sessions, (session) => this.snapshot(session));
|
||||
}
|
||||
|
||||
getSnapshot(id: string): RuntimeSnapshot | null {
|
||||
const session = this.sessions.get(id);
|
||||
return session ? this.snapshot(session) : null;
|
||||
}
|
||||
|
||||
async rename(id: string, newName: string): Promise<RuntimeSnapshot | null> {
|
||||
if (!this.sessions.has(id)) await this.load(id);
|
||||
return this.exclusive(id, async () => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
session.scenarioName = newName;
|
||||
this.store.save(session);
|
||||
return this.snapshot(session);
|
||||
});
|
||||
}
|
||||
|
||||
async step(id: string): Promise<RuntimeSnapshot | null> {
|
||||
return this.exclusive(id, async () => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "running") return this.snapshot(session);
|
||||
try {
|
||||
if (session.turn > session.maxTurns) {
|
||||
session.status = "done";
|
||||
} else if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await runAliasResolution(session);
|
||||
await runHandoffResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
} else if (session.entityIndex >= session.entities.length) {
|
||||
session.turn++;
|
||||
session.entityIndex = 0;
|
||||
session.aliasDoneForTurn = false;
|
||||
} else {
|
||||
const info = session.entities[session.entityIndex];
|
||||
if (!info.isAgent) session.entityIndex++;
|
||||
else if (info.isPlayer) await preparePlayerTurn(session, info);
|
||||
else {
|
||||
await processNpcTurn(session, info);
|
||||
session.entityIndex++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
session.status = "error";
|
||||
session.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
this.store.save(session);
|
||||
return this.snapshot(session);
|
||||
});
|
||||
}
|
||||
|
||||
async submitPlayerAction(
|
||||
id: string,
|
||||
prose: string,
|
||||
): Promise<RuntimeSnapshot | null> {
|
||||
return this.exclusive(id, async () => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return null;
|
||||
if (session.status !== "waiting_player" || !session.waitingEntity) {
|
||||
return this.snapshot(session);
|
||||
}
|
||||
const context = session.waitingEntity;
|
||||
session.waitingEntity = undefined;
|
||||
session.status = "running";
|
||||
try {
|
||||
await executePlayerAction(session, context, prose);
|
||||
session.entityIndex++;
|
||||
} catch (error) {
|
||||
session.status = "error";
|
||||
session.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
this.store.save(session);
|
||||
return this.snapshot(session);
|
||||
});
|
||||
}
|
||||
|
||||
async regenerateAllEmbeddings(
|
||||
newProviderInstanceId?: string,
|
||||
): Promise<void> {
|
||||
if (!fs.existsSync(this.store.dataDir)) return;
|
||||
let instance = newProviderInstanceId
|
||||
? (ProviderManager.list().find((item) => item.id === newProviderInstanceId) ??
|
||||
null)
|
||||
: null;
|
||||
if (!instance || instance.type !== "embedding") {
|
||||
instance = ProviderManager.getActive("embedding");
|
||||
}
|
||||
if (!instance) {
|
||||
instance = process.env.GOOGLE_API_KEY
|
||||
? {
|
||||
id: "regen-env-fallback",
|
||||
name: "Gemini Embed (Env)",
|
||||
providerName: "google-genai",
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
isActive: true,
|
||||
modelName: "gemini-embedding-001",
|
||||
type: "embedding",
|
||||
maxContext: 0,
|
||||
}
|
||||
: {
|
||||
id: "regen-mock-fallback",
|
||||
name: "Mock Embed (Fallback)",
|
||||
providerName: "mock",
|
||||
apiKey: "",
|
||||
isActive: true,
|
||||
modelName: undefined,
|
||||
type: "embedding",
|
||||
maxContext: 0,
|
||||
};
|
||||
}
|
||||
const embeddingProvider: IEmbeddingProvider = buildEmbeddingProvider(instance);
|
||||
const files = fs
|
||||
.readdirSync(this.store.dataDir)
|
||||
.filter((file) => file.startsWith("sim-") && file.endsWith(".db"));
|
||||
for (const file of files) {
|
||||
const id = file.slice(0, -3);
|
||||
const active = this.sessions.get(id);
|
||||
const db = active?.db ?? new Database(path.join(this.store.dataDir, file));
|
||||
try {
|
||||
const rows = db
|
||||
.prepare("SELECT id, content FROM ledger_entries")
|
||||
.all() as { id: string; content: string }[];
|
||||
for (const row of rows) {
|
||||
const vector = await embeddingProvider.embed(row.content);
|
||||
db.prepare("UPDATE ledger_entries SET embedding = ? WHERE id = ?").run(
|
||||
Buffer.from(new Float32Array(vector).buffer),
|
||||
row.id,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to regenerate embeddings for ${file}:`, error);
|
||||
} finally {
|
||||
if (!active) db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private snapshot(session: RuntimeSession): RuntimeSnapshot {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
const entities = session.entities.map((entity) => {
|
||||
const actual = worldState?.getEntity(entity.id);
|
||||
return {
|
||||
...entity,
|
||||
aliases: actual ? Object.fromEntries(actual.aliases) : {},
|
||||
};
|
||||
});
|
||||
let currentLocation: string | undefined;
|
||||
if (
|
||||
worldState &&
|
||||
session.entityIndex >= 0 &&
|
||||
session.entityIndex < session.entities.length
|
||||
) {
|
||||
const actual = worldState.getEntity(
|
||||
session.entities[session.entityIndex].id,
|
||||
);
|
||||
currentLocation = actual?.locationId
|
||||
? worldState.getLocation(actual.locationId)?.id
|
||||
: undefined;
|
||||
}
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
entities,
|
||||
log: session.log,
|
||||
entityIndex: session.entityIndex,
|
||||
waitingEntity: session.waitingEntity,
|
||||
error: session.error,
|
||||
worldTime: worldState?.clock.get().toISOString(),
|
||||
currentLocation,
|
||||
};
|
||||
}
|
||||
|
||||
private resolvePlayerEntity(
|
||||
rawEntities: Array<{
|
||||
id: string;
|
||||
attributes: Map<string, { getValue(): unknown }>;
|
||||
}>,
|
||||
entities: EntityInfo[],
|
||||
name?: string,
|
||||
): string | undefined {
|
||||
if (!name) return undefined;
|
||||
const query = name.toLowerCase();
|
||||
const matched =
|
||||
rawEntities.find((entity) => entity.id === name) ??
|
||||
rawEntities.find(
|
||||
(entity) =>
|
||||
String(entity.attributes.get("name")?.getValue()).toLowerCase() ===
|
||||
query,
|
||||
) ??
|
||||
rawEntities.find((entity) => {
|
||||
const entityName = String(
|
||||
entity.attributes.get("name")?.getValue() ?? "",
|
||||
).toLowerCase();
|
||||
return entityName.includes(query) || entity.id.toLowerCase().includes(query);
|
||||
});
|
||||
if (!matched) return undefined;
|
||||
const info = entities.find((entity) => entity.id === matched.id);
|
||||
if (info) info.isPlayer = true;
|
||||
return matched.id;
|
||||
}
|
||||
|
||||
private exclusive<T>(id: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.pending.get(id) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(operation);
|
||||
this.pending.set(id, current);
|
||||
void current.then(() => {
|
||||
if (this.pending.get(id) === current) this.pending.delete(id);
|
||||
}, () => {
|
||||
if (this.pending.get(id) === current) this.pending.delete(id);
|
||||
});
|
||||
return current;
|
||||
}
|
||||
|
||||
private providerErrorSnapshot(): RuntimeSnapshot {
|
||||
return this.errorSnapshot(
|
||||
"No active LLM Provider Instance found. Please configure a key in Settings first.",
|
||||
);
|
||||
}
|
||||
|
||||
private errorSnapshot(error: string): RuntimeSnapshot {
|
||||
return {
|
||||
id: "",
|
||||
status: "error",
|
||||
turn: 0,
|
||||
maxTurns: 20,
|
||||
scenarioName: "",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use RuntimeService. */
|
||||
export class SimulationManager extends RuntimeService { }
|
||||
49
packages/runtime/src/session.ts
Normal file
49
packages/runtime/src/session.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { SQLiteRepository } from "@omnia/core";
|
||||
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
import type {
|
||||
EntityInfo,
|
||||
LogEntry,
|
||||
RuntimeStatus,
|
||||
WaitingContext,
|
||||
} from "./snapshot.js";
|
||||
|
||||
export interface SavedSessionState {
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
entities: EntityInfo[];
|
||||
playerEntityId: string | undefined;
|
||||
entityIndex: number;
|
||||
status: RuntimeStatus;
|
||||
error?: string;
|
||||
waitingEntity?: WaitingContext;
|
||||
aliasDoneForTurn: boolean;
|
||||
log: LogEntry[];
|
||||
providerMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RuntimeSession extends SavedSessionState {
|
||||
db: Database.Database;
|
||||
dbPath: string;
|
||||
coreRepo: SQLiteRepository;
|
||||
bufferRepo: BufferRepository;
|
||||
ledgerRepo: LedgerRepository;
|
||||
worldInstanceId: string;
|
||||
actorProvider: ILLMProvider;
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
}
|
||||
|
||||
/** @deprecated Use RuntimeSession. */
|
||||
export type SimSession = RuntimeSession;
|
||||
/** @deprecated Use SavedSessionState. */
|
||||
export type SavedState = SavedSessionState;
|
||||
101
packages/runtime/src/snapshot.ts
Normal file
101
packages/runtime/src/snapshot.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
content: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
minutesToAdvance?: number;
|
||||
}
|
||||
|
||||
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 TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
}
|
||||
|
||||
export interface ValidatorCall {
|
||||
intentIndex: number;
|
||||
intentContent: string;
|
||||
prompt?: PromptBreakdown;
|
||||
response: { isValid: boolean; reason: string };
|
||||
usage?: TokenUsage;
|
||||
}
|
||||
|
||||
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?: TokenUsage;
|
||||
decoderPrompt?: PromptBreakdown;
|
||||
decoderUsage?: TokenUsage;
|
||||
}
|
||||
|
||||
export interface EntityInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
isAgent: boolean;
|
||||
aliases?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface WaitingContext {
|
||||
entityId: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
}
|
||||
|
||||
export type RuntimeStatus = "running" | "waiting_player" | "done" | "error";
|
||||
|
||||
export interface RuntimeSnapshot {
|
||||
id: string;
|
||||
status: RuntimeStatus;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
entities: EntityInfo[];
|
||||
log: LogEntry[];
|
||||
entityIndex: number;
|
||||
waitingEntity?: WaitingContext;
|
||||
error?: string;
|
||||
worldTime?: string;
|
||||
currentLocation?: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use RuntimeSnapshot. */
|
||||
export type SimSnapshot = RuntimeSnapshot;
|
||||
18
packages/runtime/src/testing/runtime-fixtures.ts
Normal file
18
packages/runtime/src/testing/runtime-fixtures.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RuntimeSnapshot } from "../snapshot.js";
|
||||
|
||||
export function createRuntimeSnapshot(
|
||||
overrides: Partial<RuntimeSnapshot> = {},
|
||||
): RuntimeSnapshot {
|
||||
return {
|
||||
id: "sim-test",
|
||||
status: "running",
|
||||
turn: 1,
|
||||
maxTurns: 20,
|
||||
scenarioName: "Test scenario",
|
||||
scenarioDescription: "",
|
||||
entities: [],
|
||||
log: [],
|
||||
entityIndex: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
258
packages/runtime/src/turn-executor.ts
Normal file
258
packages/runtime/src/turn-executor.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
ActorAgent,
|
||||
ActorPromptBuilder,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import type { IActorProseGenerator } from "@omnia/actor";
|
||||
import type { RuntimeSession } from "./session.js";
|
||||
import type {
|
||||
EntityInfo,
|
||||
IntentInfo,
|
||||
LogEntry,
|
||||
WaitingContext,
|
||||
ValidatorCall,
|
||||
} from "./snapshot.js";
|
||||
|
||||
class FixedProseGenerator implements IActorProseGenerator {
|
||||
constructor(private readonly prose: string) { }
|
||||
|
||||
async generate(): Promise<string> {
|
||||
return this.prose;
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeIntent = Parameters<typeof buildBufferEntryForIntent>[0];
|
||||
|
||||
async function processIntents(
|
||||
intents: RuntimeIntent[],
|
||||
actorEntityId: string,
|
||||
entity: { locationId: string | null },
|
||||
worldState: NonNullable<ReturnType<RuntimeSession["coreRepo"]["loadWorldState"]>>,
|
||||
session: RuntimeSession,
|
||||
): Promise<{ intentInfos: IntentInfo[]; validatorCalls: ValidatorCall[] }> {
|
||||
const intentInfos: IntentInfo[] = [];
|
||||
const validatorCalls: ValidatorCall[] = [];
|
||||
|
||||
for (const [intentIndex, intent] of intents.entries()) {
|
||||
const outcome = await session.architect.processIntent(worldState, intent);
|
||||
const timestamp = worldState.clock.get().toISOString();
|
||||
intentInfos.push({
|
||||
type: intent.type,
|
||||
content: intent.content,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
|
||||
});
|
||||
|
||||
if (intent.type === "action" && session.architect.validator.lastResult) {
|
||||
const result = session.architect.validator.lastResult;
|
||||
validatorCalls.push({
|
||||
intentIndex,
|
||||
intentContent: intent.content,
|
||||
prompt: {
|
||||
systemPrompt: result.systemPrompt || "",
|
||||
userContext: result.userContext || "",
|
||||
components: result.components,
|
||||
},
|
||||
response: { isValid: outcome.isValid, reason: outcome.reason },
|
||||
usage: session.validatorProvider.lastCalls?.at(-1)?.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,
|
||||
intentContent: intent.content,
|
||||
response: {
|
||||
isValid: true,
|
||||
reason: outcome.reason || reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const actorEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
timestamp,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason };
|
||||
}
|
||||
session.bufferRepo.save(actorEntry);
|
||||
|
||||
if (
|
||||
entity.locationId &&
|
||||
(intent.type === "dialogue" || intent.type === "action")
|
||||
) {
|
||||
for (const other of worldState.entities.values()) {
|
||||
if (
|
||||
other.id === actorEntityId ||
|
||||
other.locationId !== entity.locationId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const observerEntry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
timestamp,
|
||||
entity.locationId,
|
||||
);
|
||||
if (intent.type === "action") {
|
||||
observerEntry.outcome = {
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
};
|
||||
}
|
||||
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { intentInfos, validatorCalls };
|
||||
}
|
||||
|
||||
function attachDecoderDetails(
|
||||
session: RuntimeSession,
|
||||
entry: LogEntry,
|
||||
): void {
|
||||
const call = session.decoderProvider.lastCalls?.at(-1);
|
||||
if (!call) return;
|
||||
const proseHeader = "=== NARRATIVE PROSE ===";
|
||||
const index = call.userContext.indexOf(proseHeader);
|
||||
const context =
|
||||
index === -1 ? call.userContext : call.userContext.substring(0, index).trim();
|
||||
const prose = index === -1 ? "" : call.userContext.substring(index).trim();
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: call.systemPrompt,
|
||||
userContext: call.userContext,
|
||||
components: [
|
||||
{ label: "System Prompt", type: "system", content: call.systemPrompt },
|
||||
{ label: "Decoder Context", type: "world", content: context },
|
||||
{ label: "Narrative Prose", type: "input", content: prose },
|
||||
],
|
||||
};
|
||||
entry.decoderUsage = call.usage;
|
||||
}
|
||||
|
||||
export async function preparePlayerTurn(
|
||||
session: RuntimeSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
const prompt = new ActorPromptBuilder(
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
).build(worldState, entity);
|
||||
session.waitingEntity = {
|
||||
entityId: info.id,
|
||||
name: info.name,
|
||||
systemPrompt: prompt.systemPrompt,
|
||||
userContext: prompt.userContext,
|
||||
};
|
||||
session.status = "waiting_player";
|
||||
}
|
||||
|
||||
export async function processNpcTurn(
|
||||
session: RuntimeSession,
|
||||
info: EntityInfo,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const result = await new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
).act(worldState, entity);
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: info.id,
|
||||
entityName: info.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
rawPrompt: {
|
||||
systemPrompt: result.systemPrompt || "",
|
||||
userContext: result.userContext || "",
|
||||
components: result.promptComponents,
|
||||
},
|
||||
usage: session.actorProvider.lastCalls?.at(-1)?.usage,
|
||||
};
|
||||
attachDecoderDetails(session, entry);
|
||||
const processed = await processIntents(
|
||||
result.intents.intents,
|
||||
info.id,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
entry.intents = processed.intentInfos;
|
||||
entry.validatorCalls = processed.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);
|
||||
}
|
||||
|
||||
export async function executePlayerAction(
|
||||
session: RuntimeSession,
|
||||
context: WaitingContext,
|
||||
prose: string,
|
||||
): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
const entity = worldState.getEntity(context.entityId);
|
||||
if (!entity) throw new Error(`Player entity "${context.entityId}" not found`);
|
||||
|
||||
const result = await new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
).act(worldState, entity);
|
||||
const entry: LogEntry = {
|
||||
turn: session.turn,
|
||||
entityId: context.entityId,
|
||||
entityName: context.name,
|
||||
narrativeProse: result.narrativeProse,
|
||||
intents: [],
|
||||
timestamp: worldState.clock.get().toISOString(),
|
||||
rawPrompt: {
|
||||
systemPrompt: result.systemPrompt || context.systemPrompt,
|
||||
userContext: result.userContext || context.userContext,
|
||||
components: result.promptComponents,
|
||||
},
|
||||
};
|
||||
attachDecoderDetails(session, entry);
|
||||
const processed = await processIntents(
|
||||
result.intents.intents,
|
||||
context.entityId,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
entry.intents = processed.intentInfos;
|
||||
entry.validatorCalls = processed.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);
|
||||
}
|
||||
33
packages/runtime/tsconfig.json
Normal file
33
packages/runtime/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../actor"
|
||||
},
|
||||
{
|
||||
"path": "../architect"
|
||||
},
|
||||
{
|
||||
"path": "../core"
|
||||
},
|
||||
{
|
||||
"path": "../llm"
|
||||
},
|
||||
{
|
||||
"path": "../memory"
|
||||
},
|
||||
{
|
||||
"path": "../scenario"
|
||||
},
|
||||
{
|
||||
"path": "../voice"
|
||||
}
|
||||
]
|
||||
}
|
||||
12289
pnpm-lock.yaml
generated
12289
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
{ "path": "./packages/spatial" },
|
||||
{ "path": "./packages/llm" },
|
||||
{ "path": "./packages/actor" },
|
||||
{ "path": "./packages/scenario" }
|
||||
{ "path": "./packages/scenario" },
|
||||
{ "path": "./packages/runtime" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
"@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"),
|
||||
"@omnia/actor": path.resolve(__dirname, "./packages/actor/src"),
|
||||
"@omnia/scenario": path.resolve(__dirname, "./packages/scenario/src"),
|
||||
"@omnia/runtime": path.resolve(__dirname, "./packages/runtime/src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user