Compare commits
32 Commits
feat/more-
...
8204e9c18e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8204e9c18e | |||
|
|
5fb0d93a52 | ||
| e09b603d14 | |||
| affee4a733 | |||
| 814f216ea7 | |||
| 7f74617077 | |||
| 264a5ea0fe | |||
| a8b2d425a1 | |||
|
|
8f6fe0dc28 | ||
| 01ec062194 | |||
| 8ff1650657 | |||
| 7baf583b13 | |||
| ee25bf4a4c | |||
|
|
82fb3c34f7 | ||
|
|
1e34becec7 | ||
|
|
f8977a14c6 | ||
| c2926261a1 | |||
| 5c3a79e8b6 | |||
| a4b620502a | |||
| 84bff92631 | |||
| 0512be647c | |||
| 1ed1edf4cf | |||
| b4bf70dbae | |||
| c6b32bb546 | |||
| 896ff6f210 | |||
| 821f6a03cd | |||
| 9223ae3750 | |||
| 32ee8cda3a | |||
| eabe49552d | |||
| 444c722708 | |||
| 64e049c976 | |||
| 74cce6edd1 |
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
@@ -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
@@ -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/
|
||||
12
README.md
@@ -91,7 +91,7 @@ Access the application locally at `http://localhost:3000`.
|
||||
|
||||
### The Actor Agent
|
||||
|
||||
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
|
||||
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its **Cognitive Buffer**, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
|
||||
|
||||
Prose is decoded into typed intents:
|
||||
|
||||
@@ -121,8 +121,8 @@ Space is a graph: `world → region → location → point of interest`, connect
|
||||
|
||||
### Memory Tiers
|
||||
|
||||
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Vector Archive (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Cognitive Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Memory Ledger (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Dossier (planned):** Each observer's subjective beliefs about another character.
|
||||
|
||||
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
|
||||
@@ -156,14 +156,14 @@ The finish line for the first milestone is small on purpose. `v0` is almost on t
|
||||
- [x] Typed intent pipeline: `dialogue` / `action` / `monologue`, decoded from free prose.
|
||||
- [x] World Architect: LLM validation plus time-delta generation, end-to-end for single actions.
|
||||
- [x] Actor Agent with epistemically-bounded prompts (self, memory, co-located entities, subjective time).
|
||||
- [x] Verbatim memory buffer with per-observer subjective serialization and alias resolution.
|
||||
- [x] Verbatim Cognitive Buffer with per-observer subjective serialization and alias resolution.
|
||||
- [x] Spatial location graph (data model; perception is co-location only).
|
||||
- [x] Scenario loader (JSON → SQLite) and a playable CLI loop with human or LLM actors.
|
||||
|
||||
**[The `v0` Milestone:](https://github.com/sortedcord/omnia-consolidated/milestone/1)**
|
||||
|
||||
- [x] Two hand-authored NPCs live in one location, playable via CLI.
|
||||
- [x] Each has buffer and vector-archive memory and recalls something said a few turns earlier.
|
||||
- [x] Each has Cognitive Buffer and Memory Ledger memory and recalls something said a few turns earlier.
|
||||
- [x] One NPC knows a fact the other does not and, provably by testing, will not leak it.
|
||||
- [x] The Architect processes at least one non-trivial action per exchange with a visible state change.
|
||||
- [x] The whole thing persists to a SQLite file and reloads identically.
|
||||
@@ -183,7 +183,7 @@ omnia/
|
||||
intent/ intent types (dialogue/action/monologue) and the prose decoder
|
||||
architect/ World Architect: LLM validation plus time-delta generation
|
||||
actor/ actor agent: epistemically-bounded prompts, pluggable prose generators
|
||||
memory/ verbatim buffer; later the vector archive, dossier, and affect vectors
|
||||
memory/ Cognitive Buffer; Memory Ledger (vector archive), dossier, and affect vectors
|
||||
spatial/ location and POI graph, portal-based perception
|
||||
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
|
||||
scenario/ scenario JSON schema and loader (JSON → SQLite)
|
||||
|
||||
59
apps/gui/Dockerfile
Normal file
@@ -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
@@ -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"]
|
||||
2
apps/gui/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -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,9 @@
|
||||
},
|
||||
"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",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
|
||||
BIN
apps/gui/public/calendar_logo.png
Normal file
|
After Width: | Height: | Size: 607 B |
BIN
apps/gui/public/claude_logo.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/gui/public/clock_day_icon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/gui/public/clock_night_icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/gui/public/deepseek_logo.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/gui/public/gemini_logo.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/gui/public/groq_logo.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/gui/public/map_pointer_icon.png
Normal file
|
After Width: | Height: | Size: 463 B |
BIN
apps/gui/public/ollama_logo.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/gui/public/openai_logo.webp
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/gui/public/openrouter_logo.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
@@ -38,6 +38,7 @@ export async function startSimulation(input: {
|
||||
scenario?: string;
|
||||
playEntity?: string;
|
||||
providerInstanceId?: string;
|
||||
customName?: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const scenarioFile =
|
||||
@@ -52,6 +53,7 @@ export async function startSimulation(input: {
|
||||
resolved,
|
||||
input.playEntity || undefined,
|
||||
input.providerInstanceId,
|
||||
input.customName,
|
||||
);
|
||||
|
||||
if (snapshot.status === "error") {
|
||||
@@ -269,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);
|
||||
}
|
||||
@@ -348,3 +356,21 @@ export async function fetchAvailableModelsForInstance(
|
||||
inst.endpointUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameSimulation(
|
||||
simId: string,
|
||||
newName: string,
|
||||
): Promise<{ ok: true; snapshot: SimSnapshot } | { ok: false; error: string }> {
|
||||
try {
|
||||
const snapshot = await simulationManager.rename(simId, newName);
|
||||
if (!snapshot) {
|
||||
return { ok: false, error: "Simulation session not found" };
|
||||
}
|
||||
return { ok: true, snapshot };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Failed to rename simulation",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,12 +103,12 @@ export function ConfigView() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto w-full relative">
|
||||
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
|
||||
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
|
||||
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
|
||||
Configuration
|
||||
</h1>
|
||||
<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">
|
||||
@@ -177,13 +177,13 @@ export function ConfigView() {
|
||||
{
|
||||
key: "handoff",
|
||||
label: "Memory Handoff Engine",
|
||||
desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.",
|
||||
desc: "Promotes entities' Cognitive Buffer entries to the Memory Ledger via LLM summarization and pruning.",
|
||||
type: "generative",
|
||||
},
|
||||
{
|
||||
key: "embeddings",
|
||||
label: "Text Embeddings Generator",
|
||||
desc: "Generates vector embeddings for long-term memory retrieval.",
|
||||
desc: "Generates vector embeddings for Memory Ledger retrieval.",
|
||||
type: "embedding",
|
||||
},
|
||||
].map((task) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
setActiveProviderInstance,
|
||||
regenerateEmbeddings,
|
||||
deleteProviderInstance,
|
||||
duplicateProviderInstance,
|
||||
fetchAvailableModels,
|
||||
fetchAvailableModelsForInstance,
|
||||
} from "@/app/actions";
|
||||
@@ -43,17 +44,21 @@ import {
|
||||
CardTitle,
|
||||
CardAction,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemGroup, ItemTitle } from "@/components/ui/item";
|
||||
import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
|
||||
const providerLogoMap: Record<string, string> = {
|
||||
anthropic: "/claude_logo.webp",
|
||||
"google-genai": "/gemini_logo.webp",
|
||||
openrouter: "/openrouter_logo.webp",
|
||||
ollama: "/ollama_logo.webp",
|
||||
deepseek: "/deepseek_logo.webp",
|
||||
openai: "/openai_logo.webp",
|
||||
groq: "/groq_logo.webp",
|
||||
};
|
||||
|
||||
interface ProviderInstancesConfigProps {
|
||||
instances: ModelProviderInstance[];
|
||||
availableProviders: ModelProviderMeta[];
|
||||
@@ -190,14 +195,12 @@ export function ProviderInstancesConfig({
|
||||
fetchModelsForExistingInstance(selectedInstanceId);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
|
||||
// Re-fetch models when provider/key/endpoint changes on new instance form
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId !== "new") return;
|
||||
fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editProvider, editKey, editEndpointUrl, selectedInstanceId]);
|
||||
|
||||
const handleProviderChange = (providerId: string | null) => {
|
||||
@@ -336,8 +339,25 @@ export function ProviderInstancesConfig({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const duplicated = await duplicateProviderInstance(selectedInstanceId);
|
||||
if (duplicated) {
|
||||
setSelectedInstanceId(duplicated.id);
|
||||
await onChanged();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<section className="mb-8 flex min-h-[600px] flex-col">
|
||||
{error && (
|
||||
<div className="mb-4 rounded border-2 border-red-500 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
@@ -345,7 +365,7 @@ export function ProviderInstancesConfig({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 grid min-h-[400px] grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
|
||||
"mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
|
||||
loading && "pointer-events-none opacity-60",
|
||||
"transition-opacity duration-200",
|
||||
)}
|
||||
@@ -379,14 +399,22 @@ export function ProviderInstancesConfig({
|
||||
)}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
>
|
||||
<ItemContent>
|
||||
<ItemTitle>{inst.name}</ItemTitle>
|
||||
<ItemDescription>{inst.providerName}</ItemDescription>
|
||||
<div className="flex flex-row gap-1.5">
|
||||
{inst.isActive && <Badge>Active</Badge>}
|
||||
<Badge variant="outline">
|
||||
{inst.type === "generative" ? "gen" : "embed"}
|
||||
</Badge>
|
||||
<ItemContent className="flex-row items-center gap-3">
|
||||
{providerLogoMap[inst.providerName] && (
|
||||
<img
|
||||
src={providerLogoMap[inst.providerName]}
|
||||
alt={inst.providerName}
|
||||
className="max-h-[38px] max-w-[38px] shrink-0 rounded-sm object-contain"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<ItemTitle>{inst.name}</ItemTitle>
|
||||
<div className="flex flex-row gap-1.5">
|
||||
{inst.isActive && <Badge>Active</Badge>}
|
||||
<Badge variant="outline">
|
||||
{inst.type === "generative" ? "gen" : "embed"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
@@ -431,6 +459,7 @@ export function ProviderInstancesConfig({
|
||||
<div className="grid grid-cols-[2fr_3fr] gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Instance Type</Label>
|
||||
{/* TODO: Change this to choice card */}
|
||||
<Select
|
||||
value={editType}
|
||||
onValueChange={(v) =>
|
||||
@@ -635,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,14 +25,8 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Bot, UserRound } from "lucide-react";
|
||||
|
||||
export function HomeView() {
|
||||
const router = useRouter();
|
||||
@@ -59,6 +53,7 @@ export function HomeView() {
|
||||
const [loadingEntities, setLoadingEntities] = useState(false);
|
||||
const [selectedEntityForModal, setSelectedEntityForModal] =
|
||||
useState<string>("");
|
||||
const [customName, setCustomName] = useState<string>("");
|
||||
|
||||
const loadSavedSessions = useCallback(async () => {
|
||||
try {
|
||||
@@ -144,6 +139,7 @@ export function HomeView() {
|
||||
setScenarioForModal(scenario);
|
||||
setLoadingEntities(true);
|
||||
setSelectedEntityForModal(""); // Reset selection to Spectator
|
||||
setCustomName(scenario.name); // Set custom simulation name default
|
||||
try {
|
||||
const res = await getScenarioEntities(scenario.path);
|
||||
if (res.ok) {
|
||||
@@ -168,6 +164,7 @@ export function HomeView() {
|
||||
const result = await startSimulation({
|
||||
scenario: targetScenario.path,
|
||||
playEntity: selectedEntityForModal || undefined,
|
||||
customName: customName.trim() || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -188,7 +185,7 @@ export function HomeView() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto w-full relative">
|
||||
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
|
||||
<div className="relative z-10 mx-auto max-w-5xl px-10 py-12">
|
||||
<div className="animate-fade-in">
|
||||
{/* Centered Big Logo */}
|
||||
<div className="flex flex-col items-center justify-center mb-10 pt-4">
|
||||
@@ -233,7 +230,7 @@ export function HomeView() {
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-[148px]"
|
||||
className="shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-37"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
@@ -260,7 +257,7 @@ export function HomeView() {
|
||||
? undefined
|
||||
: () => handleResume(s.id)
|
||||
}
|
||||
className={`flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
|
||||
className={`shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
|
||||
providerInstances.length === 0
|
||||
? "opacity-50 cursor-not-allowed filter grayscale"
|
||||
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
|
||||
@@ -306,8 +303,8 @@ export function HomeView() {
|
||||
</h2>
|
||||
{loadingData ? (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
<Link href="/builder" className="no-underline flex-shrink-0">
|
||||
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
|
||||
<Link href="/builder" className="no-underline shrink-0">
|
||||
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
|
||||
<div>
|
||||
<strong className="text-body-md text-foreground block mb-1">
|
||||
Build a scenario
|
||||
@@ -327,7 +324,7 @@ export function HomeView() {
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-[148px]"
|
||||
className="shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-37"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
@@ -341,8 +338,8 @@ export function HomeView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
|
||||
<Link href="/builder" className="no-underline flex-shrink-0">
|
||||
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
|
||||
<Link href="/builder" className="no-underline shrink-0">
|
||||
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
|
||||
<div>
|
||||
<strong className="text-body-md text-surface block mb-1">
|
||||
Build a scenario
|
||||
@@ -378,7 +375,7 @@ export function HomeView() {
|
||||
open={!!scenarioForModal}
|
||||
onOpenChange={(open) => !open && setScenarioForModal(null)}
|
||||
>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogContent className="max-w-100">
|
||||
<DialogHeader className="border-b border-dotted border-border/20 pb-4 mb-2">
|
||||
<DialogTitle>Start Scenario</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -394,32 +391,51 @@ export function HomeView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Custom Simulation Name
|
||||
</label>
|
||||
<Input
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder="Enter custom name..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Simulation Mode / Play as
|
||||
</label>
|
||||
<Select
|
||||
value={selectedEntityForModal}
|
||||
onValueChange={(val) =>
|
||||
setSelectedEntityForModal(val || "")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="-- Run Fully Autonomously --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="">
|
||||
-- Run Fully Autonomously --
|
||||
</SelectItem>
|
||||
{modalEntities.map((ent) => (
|
||||
<SelectItem key={ent.id} value={ent.id}>
|
||||
Play as {ent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={
|
||||
selectedEntityForModal === ""
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => setSelectedEntityForModal("")}
|
||||
className="flex-1 min-w-40"
|
||||
>
|
||||
<Bot />
|
||||
Run Fully Autonomously
|
||||
</Button>
|
||||
{modalEntities.map((ent) => (
|
||||
<Button
|
||||
key={ent.id}
|
||||
type="button"
|
||||
variant={
|
||||
selectedEntityForModal === ent.id
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => setSelectedEntityForModal(ent.id)}
|
||||
className="flex-1 min-w-40"
|
||||
>
|
||||
<UserRound />
|
||||
Play as {ent.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
260
apps/gui/src/components/play/HandoffModal.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PromptAnalyzer } from "@/components/play/PromptAnalyzer";
|
||||
|
||||
interface HandoffModalProps {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HandoffModal({ entry, onClose }: HandoffModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"chunks" | "prompt" | "output">(
|
||||
"chunks",
|
||||
);
|
||||
|
||||
const handoffResult = entry.handoffResult;
|
||||
const chunks = handoffResult?.chunks || [];
|
||||
|
||||
const getImportanceColor = (score: number) => {
|
||||
if (score >= 8)
|
||||
return "bg-destructive/10 text-destructive border-destructive/30";
|
||||
if (score >= 5) return "bg-amber-500/10 text-amber-500 border-amber-500/30";
|
||||
return "bg-emerald-500/10 text-emerald-500 border-emerald-500/30";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-200 sm:max-w-200 h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
|
||||
<DialogHeader className="px-6 pt-5 pb-4 border-b">
|
||||
<DialogTitle className="text-lg font-head tracking-wide text-primary flex items-center justify-between">
|
||||
<span>Memory Handoff Details — {entry.entityName}</span>
|
||||
{entry.usage && (
|
||||
<span className="text-xs font-mono font-normal text-muted-foreground">
|
||||
{entry.usage.modelName || "Handoff Model"}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Custom Tab Switcher */}
|
||||
<div className="flex border-b bg-muted/20 px-6 py-2 gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("chunks")}
|
||||
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
|
||||
activeTab === "chunks"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
|
||||
: "border-transparent hover:bg-secondary text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Promoted Chunks ({chunks.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("prompt")}
|
||||
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
|
||||
activeTab === "prompt"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
|
||||
: "border-transparent hover:bg-secondary text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Raw LLM Prompt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("output")}
|
||||
className={`px-3 py-1.5 text-xs font-medium border transition-all duration-100 ${
|
||||
activeTab === "output"
|
||||
? "border-primary bg-primary/10 text-primary shadow-[1px_1px_0_0_var(--primary)]"
|
||||
: "border-transparent hover:bg-secondary text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Raw JSON Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-6 space-y-4">
|
||||
{activeTab === "chunks" && (
|
||||
<div className="space-y-4">
|
||||
{entry.usage && (
|
||||
<div className="grid grid-cols-3 gap-4 border border-dotted border-border/20 p-3 bg-secondary/10 rounded text-xs font-mono">
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
|
||||
Input Tokens
|
||||
</span>
|
||||
<strong className="text-foreground">
|
||||
{entry.usage.inputTokens}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
|
||||
Output Tokens
|
||||
</span>
|
||||
<strong className="text-foreground">
|
||||
{entry.usage.outputTokens}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider text-[10px]">
|
||||
Total Tokens
|
||||
</span>
|
||||
<strong className="text-foreground">
|
||||
{entry.usage.totalTokens}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chunks.length === 0 ? (
|
||||
<div className="border border-dotted border-border/30 p-8 text-center bg-card text-muted-foreground rounded">
|
||||
<p className="text-sm">
|
||||
No memories were promoted to the Memory Ledger during this
|
||||
turn.
|
||||
</p>
|
||||
<p className="text-xs mt-1">
|
||||
All Cognitive Buffer entries were summarized or forgotten.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Memory Ledger Additions
|
||||
</h3>
|
||||
{chunks.map(
|
||||
(
|
||||
chunk: {
|
||||
content: string;
|
||||
importance: number;
|
||||
quotes?: string[];
|
||||
retainInBuffer?: boolean;
|
||||
involvedEntityIds?: string[];
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border/30 bg-card p-4 shadow-sm relative flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1 text-sm text-foreground/90 leading-relaxed font-sans">
|
||||
{chunk.content}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`font-mono text-xs ${getImportanceColor(chunk.importance)}`}
|
||||
>
|
||||
Importance: {chunk.importance}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{chunk.quotes && chunk.quotes.length > 0 && (
|
||||
<div className="bg-secondary/10 border-l-2 border-primary/50 p-2.5 my-1 text-xs italic text-muted-foreground space-y-1">
|
||||
{chunk.quotes.map((quote: string, qIdx: number) => (
|
||||
<div key={qIdx}>“{quote}”</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs pt-2 border-t border-dotted border-border/10">
|
||||
{chunk.retainInBuffer ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-primary/5 text-primary border-primary/20 text-[10px] font-mono"
|
||||
>
|
||||
Pinned in Buffer
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-muted text-muted-foreground border-border/20 text-[10px] font-mono"
|
||||
>
|
||||
Pruned from Buffer
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{chunk.involvedEntityIds &&
|
||||
chunk.involvedEntityIds.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 ml-auto text-[10px] font-mono text-muted-foreground">
|
||||
<span>Entities:</span>
|
||||
{chunk.involvedEntityIds.map(
|
||||
(entId: string) => (
|
||||
<Badge
|
||||
key={entId}
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 border-border/20 font-mono"
|
||||
>
|
||||
{entId}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "prompt" && entry.rawPrompt && (
|
||||
<PromptAnalyzer
|
||||
components={
|
||||
entry.rawPrompt.components &&
|
||||
entry.rawPrompt.components.length > 0
|
||||
? entry.rawPrompt.components
|
||||
: [
|
||||
{
|
||||
label: "System Prompt",
|
||||
type: "system",
|
||||
content: entry.rawPrompt.systemPrompt || "",
|
||||
},
|
||||
{
|
||||
label: "User Context",
|
||||
type: "world",
|
||||
content: entry.rawPrompt.userContext || "",
|
||||
},
|
||||
]
|
||||
}
|
||||
inputTokens={entry.usage?.inputTokens || 0}
|
||||
maxContext={
|
||||
entry.usage?.maxContext !== undefined
|
||||
? entry.usage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={entry.usage?.modelName}
|
||||
providerInstanceName={entry.usage?.providerInstanceName}
|
||||
outputLabel="LLM Output (Promoted Memory Chunks)"
|
||||
outputText={
|
||||
handoffResult
|
||||
? JSON.stringify(handoffResult, null, 2)
|
||||
: undefined
|
||||
}
|
||||
outputTokens={entry.usage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "output" && (
|
||||
<div className="space-y-2 h-full flex flex-col">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Raw JSON Output
|
||||
</h4>
|
||||
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border flex-1 overflow-y-auto max-h-125">
|
||||
{handoffResult
|
||||
? JSON.stringify(handoffResult, null, 2)
|
||||
: "No JSON Output recorded."}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
275
apps/gui/src/components/play/InteractView.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { hydrate } from "@omnia/voice";
|
||||
import { Brain, PersonStanding, Speech } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { InteractDock } from "./InteractDock";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
playerAliases,
|
||||
playerId,
|
||||
entities,
|
||||
}: {
|
||||
intent: SimSnapshot["log"][number]["intents"][number];
|
||||
playerAliases: Record<string, string>;
|
||||
playerId: string;
|
||||
entities: SimSnapshot["entities"];
|
||||
}) {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
monologue: <Brain className="size-4" />,
|
||||
thought: <Brain className="size-4" />,
|
||||
dialogue: <Speech className="size-4" />,
|
||||
action: <PersonStanding className="size-4" />,
|
||||
};
|
||||
|
||||
const icon = icons[intent.type] || null;
|
||||
|
||||
const viewerAliasesMap = new Map<string, string>();
|
||||
if (entities) {
|
||||
for (const ent of entities) {
|
||||
viewerAliasesMap.set(ent.id, ent.name || ent.id);
|
||||
}
|
||||
}
|
||||
if (playerAliases) {
|
||||
for (const [targetId, alias] of Object.entries(playerAliases)) {
|
||||
viewerAliasesMap.set(targetId, alias);
|
||||
}
|
||||
}
|
||||
|
||||
const viewerEntityMock = {
|
||||
id: playerId || "",
|
||||
aliases: viewerAliasesMap,
|
||||
};
|
||||
|
||||
const textToDisplay = hydrate(
|
||||
intent.content,
|
||||
viewerEntityMock as unknown as Parameters<typeof hydrate>[1],
|
||||
);
|
||||
|
||||
const modifiersStr =
|
||||
intent.modifiers && intent.modifiers.length > 0 ? (
|
||||
<span className="italic opacity-80 text-muted-foreground ml-1">
|
||||
({intent.modifiers.join(", ")})
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const invalidActionReason =
|
||||
intent.type === "action" && !intent.isValid && intent.reason
|
||||
? ` (${intent.reason})`
|
||||
: "";
|
||||
|
||||
const invalidActionClassName =
|
||||
intent.type === "action" && !intent.isValid ? " text-destructive" : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground inline-flex items-start gap-1">
|
||||
<span className="mt-0.5 inline-flex shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<span className={invalidActionClassName}>
|
||||
“{textToDisplay}”{modifiersStr}
|
||||
{invalidActionReason}
|
||||
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSimTime(isoString: string) {
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return isoString;
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const min = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function LogEntryCard({
|
||||
entry,
|
||||
onShowPrompt,
|
||||
isPlayerCard,
|
||||
playerAliases,
|
||||
playerId,
|
||||
entities,
|
||||
}: {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
isPlayerCard: boolean;
|
||||
playerAliases: Record<string, string>;
|
||||
playerId: string;
|
||||
entities: SimSnapshot["entities"];
|
||||
}) {
|
||||
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
|
||||
|
||||
return (
|
||||
<div className={cn("mb-2")}>
|
||||
<div
|
||||
className={cn(
|
||||
"border p-4 shadow-sm",
|
||||
isPlayerCard
|
||||
? "border-primary bg-surface-container-low"
|
||||
: "border-border/30 bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="text-body-md font-bold text-foreground">
|
||||
{entry.entityName}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Turn {entry.turn} · {formatSimTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onShowPrompt(entry)}
|
||||
title="View Raw Prompts & Token Usage"
|
||||
>
|
||||
☰
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
|
||||
{entry.narrativeProse}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("mt-3 ms-3")}>
|
||||
{entry.intents.map((intent, i) => (
|
||||
<IntentTag
|
||||
key={i}
|
||||
intent={intent}
|
||||
playerAliases={playerAliases}
|
||||
playerId={playerId}
|
||||
entities={entities}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InteractViewProps {
|
||||
snapshot: SimSnapshot;
|
||||
loading: boolean;
|
||||
statusText: string;
|
||||
playerInput: string;
|
||||
setPlayerInput: (value: string) => void;
|
||||
onSubmitAction: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
onShowHandoff: (entry: SimSnapshot["log"][number]) => void;
|
||||
logEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
onPauseRequested: () => void;
|
||||
onResumeRequested: () => void;
|
||||
onStopRequested: () => void;
|
||||
}
|
||||
|
||||
export function InteractView({
|
||||
snapshot,
|
||||
loading,
|
||||
statusText,
|
||||
playerInput,
|
||||
setPlayerInput,
|
||||
onSubmitAction,
|
||||
onShowPrompt,
|
||||
onShowHandoff,
|
||||
logEndRef,
|
||||
onPauseRequested,
|
||||
onResumeRequested,
|
||||
onStopRequested,
|
||||
}: InteractViewProps) {
|
||||
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Scrollable Center Viewport */}
|
||||
<main className="flex-1 overflow-y-auto px-8 py-6">
|
||||
<div className="flex flex-col gap-4 max-w-200 mx-auto pb-44 md:pb-52">
|
||||
{snapshot.log.map((entry, i) => {
|
||||
if (entry.isHandoff) {
|
||||
return (
|
||||
<Alert
|
||||
key={i}
|
||||
className="max-w-md border-dashed bg-secondary/10"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<AlertTitle>
|
||||
Handoff triggered for {entry.entityName}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Memories were transferred from Cognitive Buffer to Memory
|
||||
Ledger
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<AlertAction>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => onShowHandoff(entry)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
const playerAliases = playerEntity?.aliases || {};
|
||||
const playerId = playerEntity?.id || "";
|
||||
|
||||
return (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={onShowPrompt}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
playerAliases={playerAliases}
|
||||
playerId={playerId}
|
||||
entities={snapshot.entities}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
|
||||
<Spinner />
|
||||
{statusText || "Processing..."}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<InteractDock
|
||||
snapshot={snapshot}
|
||||
loading={loading}
|
||||
playerInput={playerInput}
|
||||
setPlayerInput={setPlayerInput}
|
||||
onSubmitAction={onSubmitAction}
|
||||
onPauseRequested={onPauseRequested}
|
||||
onResumeRequested={onResumeRequested}
|
||||
onStopRequested={onStopRequested}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
apps/gui/src/components/play/ManageView.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { renameSimulation } from "@/app/actions";
|
||||
|
||||
interface ManageViewProps {
|
||||
snapshot: SimSnapshot;
|
||||
onRename: (updated: SimSnapshot) => void;
|
||||
}
|
||||
|
||||
export function ManageView({ snapshot, onRename }: ManageViewProps) {
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editedName, setEditedName] = useState(snapshot.scenarioName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditedName(snapshot.scenarioName);
|
||||
}, [snapshot.scenarioName]);
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!editedName.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await renameSimulation(snapshot.id, editedName.trim());
|
||||
if (res.ok) {
|
||||
onRename(res.snapshot);
|
||||
setIsEditingName(false);
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to rename simulation",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto px-8 py-6">
|
||||
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
|
||||
{/* Simulation Info */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Simulation Info
|
||||
</h3>
|
||||
{error && (
|
||||
<div className="mb-4 border border-destructive bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2 md:col-span-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Simulation Name
|
||||
</span>
|
||||
{isEditingName ? (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Input
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
className="h-8 max-w-sm font-sans"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button size="sm" onClick={handleSaveName} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsEditingName(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-foreground font-bold text-base font-head">
|
||||
{snapshot.scenarioName}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
setEditedName(snapshot.scenarioName);
|
||||
setIsEditingName(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Session ID
|
||||
</span>
|
||||
<span className="text-foreground font-bold break-all">
|
||||
{snapshot.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Max Turns
|
||||
</span>
|
||||
<span className="text-foreground font-bold font-mono">
|
||||
{snapshot.maxTurns}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Turn Count
|
||||
</span>
|
||||
<span className="text-foreground font-bold font-mono">
|
||||
{snapshot.turn}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Entities Registered
|
||||
</span>
|
||||
<span className="text-foreground font-bold font-mono">
|
||||
{snapshot.entities.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entities Involved */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Entities Involved
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{snapshot.entities.map((ent) => (
|
||||
<div
|
||||
key={ent.id}
|
||||
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<strong className="text-sm text-foreground block font-head tracking-wide">
|
||||
{ent.name}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono block mt-1">
|
||||
ID: {ent.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ent.isPlayer ? (
|
||||
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
|
||||
PLAYER
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
|
||||
NPC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from "@/app/actions";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PromptModal } from "./PromptModal";
|
||||
import { HandoffModal } from "./HandoffModal";
|
||||
import { InteractView } from "./InteractView";
|
||||
import { ManageView } from "./ManageView";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import {
|
||||
@@ -22,114 +24,6 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
isSelf,
|
||||
}: {
|
||||
intent: SimSnapshot["log"][number]["intents"][number];
|
||||
isSelf?: boolean;
|
||||
}) {
|
||||
const labels: Record<string, string> = {
|
||||
monologue: "thought",
|
||||
dialogue: "dialogue",
|
||||
action: "action",
|
||||
};
|
||||
|
||||
const label = labels[intent.type] || intent.type;
|
||||
|
||||
let outcome = "";
|
||||
if (intent.type === "action") {
|
||||
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
|
||||
}
|
||||
|
||||
const textToDisplay =
|
||||
isSelf && intent.selfDescription
|
||||
? intent.selfDescription
|
||||
: intent.description;
|
||||
|
||||
const modifiersStr =
|
||||
intent.modifiers && intent.modifiers.length > 0 ? (
|
||||
<span className="italic opacity-80 text-muted-foreground ml-1">
|
||||
({intent.modifiers.join(", ")})
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
[{label}] “{textToDisplay}”{modifiersStr}
|
||||
{outcome}
|
||||
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSimTime(isoString: string) {
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return isoString;
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const min = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function LogEntryCard({
|
||||
entry,
|
||||
onShowPrompt,
|
||||
isPlayerCard,
|
||||
}: {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
isPlayerCard: boolean;
|
||||
}) {
|
||||
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
|
||||
isPlayerCard
|
||||
? "border-primary bg-surface-container-low"
|
||||
: "border-border/30 bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="text-body-md font-bold text-foreground">
|
||||
{entry.entityName}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Turn {entry.turn} · {formatSimTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onShowPrompt(entry)}
|
||||
title="View Raw Prompts & Token Usage"
|
||||
>
|
||||
☰
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
|
||||
{entry.narrativeProse}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
|
||||
{entry.intents.map((intent, i) => (
|
||||
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MobileSidebarClose() {
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
if (!isMobile) return null;
|
||||
@@ -163,6 +57,9 @@ export function PlayView() {
|
||||
const [selectedEntryForModal, setSelectedEntryForModal] = useState<
|
||||
SimSnapshot["log"][number] | null
|
||||
>(null);
|
||||
const [selectedHandoffForModal, setSelectedHandoffForModal] = useState<
|
||||
SimSnapshot["log"][number] | null
|
||||
>(null);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const steppingRef = useRef(false);
|
||||
@@ -422,43 +319,6 @@ export function PlayView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Simulation Global Controls */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{snapshot.status !== "done" && snapshot.status !== "error" && (
|
||||
<>
|
||||
{snapshot.status === "running" &&
|
||||
(loading ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => runSteps(snapshot.id)}
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
|
||||
<span className="text-muted-foreground">
|
||||
@@ -482,171 +342,28 @@ export function PlayView() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Scrollable Center Viewport */}
|
||||
<main className="flex-1 overflow-y-auto px-8 py-6">
|
||||
{activeTab === "interact" ? (
|
||||
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
|
||||
{(() => {
|
||||
const playerEntity = snapshot.entities.find(
|
||||
(e) => e.isPlayer,
|
||||
);
|
||||
return snapshot.log.map((entry, i) => (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
|
||||
<Spinner />
|
||||
{statusText || "Processing..."}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
|
||||
{/* Simulation Info */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Simulation Info
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Session ID
|
||||
</span>
|
||||
<span className="text-foreground font-bold break-all">
|
||||
{snapshot.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Max Turns
|
||||
</span>
|
||||
<span className="text-foreground font-bold">
|
||||
{snapshot.maxTurns}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Turn Count
|
||||
</span>
|
||||
<span className="text-foreground font-bold">
|
||||
{snapshot.turn}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Entities Registered
|
||||
</span>
|
||||
<span className="text-foreground font-bold">
|
||||
{snapshot.entities.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entities Involved */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Entities Involved
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{snapshot.entities.map((ent) => (
|
||||
<div
|
||||
key={ent.id}
|
||||
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<strong className="text-sm text-foreground block font-head tracking-wide">
|
||||
{ent.name}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono block mt-1">
|
||||
ID: {ent.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ent.isPlayer ? (
|
||||
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
|
||||
PLAYER
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
|
||||
NPC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Sticky Chat / Interaction Input Footer */}
|
||||
{activeTab === "interact" && (
|
||||
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
|
||||
<div className="max-w-[800px] mx-auto">
|
||||
{snapshot.status === "waiting_player" &&
|
||||
snapshot.waitingEntity ? (
|
||||
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
|
||||
<strong>
|
||||
Your context as {snapshot.waitingEntity.name}
|
||||
</strong>
|
||||
</summary>
|
||||
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
|
||||
{snapshot.waitingEntity.userContext}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmitAction}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Textarea
|
||||
value={playerInput}
|
||||
onChange={(e) => setPlayerInput(e.target.value)}
|
||||
placeholder="Describe what your character does, says, or thinks..."
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !playerInput.trim()}
|
||||
>
|
||||
{loading ? "Processing..." : "Submit Action"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : snapshot.status === "done" ||
|
||||
snapshot.status === "error" ? (
|
||||
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<span className="text-sm font-mono text-muted-foreground">
|
||||
{snapshot.status === "error"
|
||||
? "Simulation finished with an error."
|
||||
: "Simulation complete."}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{snapshot.status === "error"
|
||||
? "Back to Dashboard"
|
||||
: "New Simulation"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
{activeTab === "interact" ? (
|
||||
<InteractView
|
||||
snapshot={snapshot}
|
||||
loading={loading}
|
||||
statusText={statusText}
|
||||
playerInput={playerInput}
|
||||
setPlayerInput={setPlayerInput}
|
||||
onSubmitAction={handleSubmitAction}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
onShowHandoff={setSelectedHandoffForModal}
|
||||
logEndRef={logEndRef}
|
||||
onPauseRequested={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
}}
|
||||
onResumeRequested={() => runSteps(snapshot.id)}
|
||||
onStopRequested={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
router.push("/");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ManageView snapshot={snapshot} onRename={setSnapshot} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -662,6 +379,13 @@ export function PlayView() {
|
||||
onClose={() => setSelectedEntryForModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedHandoffForModal && (
|
||||
<HandoffModal
|
||||
entry={selectedHandoffForModal}
|
||||
onClose={() => setSelectedHandoffForModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
177
apps/gui/src/components/play/PromptAnalyzer.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import type { PromptComponent } from "@/lib/simulation-types";
|
||||
|
||||
interface PromptAnalyzerProps {
|
||||
components: PromptComponent[];
|
||||
inputTokens: number;
|
||||
maxContext?: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
outputLabel?: string;
|
||||
outputText?: string;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export function PromptAnalyzer({
|
||||
components,
|
||||
inputTokens,
|
||||
maxContext = 32768,
|
||||
modelName,
|
||||
providerInstanceName,
|
||||
outputLabel = "LLM Output",
|
||||
outputText,
|
||||
outputTokens,
|
||||
}: PromptAnalyzerProps) {
|
||||
const totalLen = components.reduce((sum, s) => sum + s.content.length, 0);
|
||||
|
||||
if (totalLen === 0) {
|
||||
return (
|
||||
<div className="text-sm italic text-muted-foreground">
|
||||
No prompt context recorded.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = components.map((s) => {
|
||||
const pct = totalLen > 0 ? (s.content.length / totalLen) * 100 : 0;
|
||||
return {
|
||||
...s,
|
||||
pct,
|
||||
tokens: Math.round((s.content.length / totalLen) * inputTokens),
|
||||
};
|
||||
});
|
||||
|
||||
const usagePctOfContext =
|
||||
maxContext > 0 ? (inputTokens / maxContext) * 100 : 0;
|
||||
const isAbsolute = maxContext > 0 && usagePctOfContext >= 20;
|
||||
|
||||
const legentColors = [
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-purple-500",
|
||||
"bg-orange-500",
|
||||
"bg-pink-500",
|
||||
"bg-amber-500",
|
||||
"bg-teal-500",
|
||||
"bg-cyan-500",
|
||||
"bg-indigo-500",
|
||||
"bg-violet-500",
|
||||
"bg-rose-500",
|
||||
"bg-sky-500",
|
||||
"bg-lime-500",
|
||||
"bg-fuchsia-500",
|
||||
"bg-red-500",
|
||||
];
|
||||
|
||||
const getColorClass = (index: number) => {
|
||||
return legentColors[index % legentColors.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Provider Details */}
|
||||
{(providerInstanceName || modelName) && (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong>{" "}
|
||||
<span>{providerInstanceName || "Default"}</span>
|
||||
{modelName && <span> ({modelName})</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar & Breakdown */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">Input Prompt Breakdown</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{inputTokens}</strong>
|
||||
{maxContext > 0 ? (
|
||||
<span>
|
||||
{" "}
|
||||
/ {maxContext} ({usagePctOfContext.toFixed(1)}% used)
|
||||
</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Token Bar */}
|
||||
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{sections.map((item, idx) => {
|
||||
const widthPct = isAbsolute
|
||||
? item.pct * (inputTokens / maxContext)
|
||||
: item.pct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${getColorClass(idx)}`}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - usagePctOfContext}%` }}
|
||||
title={`Available: ${maxContext - inputTokens} tokens (${(100 - usagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accordion Components */}
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{sections.map((item, idx) => {
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-sm ${getColorClass(idx)}`}
|
||||
/>
|
||||
<span>{item.label}:</span>
|
||||
<span className="text-muted-foreground font-normal">
|
||||
<strong>{item.tokens}</strong> tokens (
|
||||
{item.pct.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-75 overflow-y-auto">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Output Section */}
|
||||
{outputText && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2 font-mono">
|
||||
<span className="font-semibold">{outputLabel}</span>
|
||||
{outputTokens !== undefined && (
|
||||
<span>
|
||||
Total Output Tokens: <strong>{outputTokens}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-3 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground max-h-62.5 overflow-y-auto">
|
||||
{outputText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import type { SimSnapshot, PromptBreakdown } from "@/lib/simulation-types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,13 +9,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import { PromptSwitcher } from "@/components/play/PromptSwitcher";
|
||||
import { PromptAnalyzer } from "@/components/play/PromptAnalyzer";
|
||||
|
||||
interface PromptModalProps {
|
||||
entry: SimSnapshot["log"][number];
|
||||
@@ -23,172 +18,7 @@ interface PromptModalProps {
|
||||
}
|
||||
|
||||
export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor");
|
||||
|
||||
const parseActorPrompt = (
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
inputTokens: number,
|
||||
) => {
|
||||
const recentHeader = "=== RECENT EVENTS ===";
|
||||
const ledgerHeader = "=== YOUR MEMORIES ===";
|
||||
|
||||
const recentIdx = userContext.indexOf(recentHeader);
|
||||
let worldStr = userContext;
|
||||
let recentStr = "";
|
||||
let ledgerStr = "";
|
||||
|
||||
if (recentIdx !== -1) {
|
||||
worldStr = userContext.substring(0, recentIdx).trim();
|
||||
const rest = userContext.substring(recentIdx).trim();
|
||||
const ledgerIdx = rest.indexOf(ledgerHeader);
|
||||
if (ledgerIdx !== -1) {
|
||||
recentStr = rest.substring(0, ledgerIdx).trim();
|
||||
ledgerStr = rest.substring(ledgerIdx).trim();
|
||||
} else {
|
||||
recentStr = rest;
|
||||
}
|
||||
}
|
||||
|
||||
const sections: { label: string; type: string; content: string }[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "World Info", type: "world", content: worldStr },
|
||||
{
|
||||
label: "Recent Events",
|
||||
type: "events",
|
||||
content: recentStr || "(No recent events.)",
|
||||
},
|
||||
{
|
||||
label: "Long-Term Memories",
|
||||
type: "memories",
|
||||
content: ledgerStr || "(No long-term memories.)",
|
||||
},
|
||||
];
|
||||
|
||||
const totalLen = sections.reduce((sum, s) => sum + s.content.length, 0);
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
return sections.map((s) => {
|
||||
const pct = (s.content.length / totalLen) * 100;
|
||||
return {
|
||||
...s,
|
||||
pct,
|
||||
relativePct: pct,
|
||||
tokens: Math.round((s.content.length / totalLen) * inputTokens),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const parseDecoderPrompt = (
|
||||
systemPrompt: string,
|
||||
userContext: string,
|
||||
inputTokens: number,
|
||||
) => {
|
||||
const proseHeader = "=== NARRATIVE PROSE ===";
|
||||
const idx = userContext.indexOf(proseHeader);
|
||||
|
||||
let worldStr = userContext;
|
||||
let proseStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
worldStr = userContext.substring(0, idx).trim();
|
||||
proseStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
const sysLen = systemPrompt.length;
|
||||
const worldLen = worldStr.length;
|
||||
const proseLen = proseStr.length;
|
||||
const totalLen = sysLen + worldLen + proseLen;
|
||||
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
const sysPct = (sysLen / totalLen) * 100;
|
||||
const worldPct = (worldLen / totalLen) * 100;
|
||||
const prosePct = (proseLen / totalLen) * 100;
|
||||
|
||||
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
|
||||
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
|
||||
const proseTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "System Prompt",
|
||||
pct: sysPct,
|
||||
relativePct: sysPct,
|
||||
tokens: sysTokens,
|
||||
type: "system",
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
label: "Decoder Context",
|
||||
pct: worldPct,
|
||||
relativePct: worldPct,
|
||||
tokens: worldTokens,
|
||||
type: "world",
|
||||
content: worldStr,
|
||||
},
|
||||
{
|
||||
label: "Narrative Prose",
|
||||
pct: prosePct,
|
||||
relativePct: prosePct,
|
||||
tokens: proseTokens,
|
||||
type: "memories",
|
||||
content: proseStr,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const actorBreakdown =
|
||||
entry.rawPrompt && entry.usage
|
||||
? parseActorPrompt(
|
||||
entry.rawPrompt.systemPrompt,
|
||||
entry.rawPrompt.userContext,
|
||||
entry.usage.inputTokens,
|
||||
)
|
||||
: null;
|
||||
const decoderBreakdown =
|
||||
entry.decoderPrompt && entry.decoderUsage
|
||||
? parseDecoderPrompt(
|
||||
entry.decoderPrompt.systemPrompt,
|
||||
entry.decoderPrompt.userContext,
|
||||
entry.decoderUsage.inputTokens,
|
||||
)
|
||||
: null;
|
||||
|
||||
const actorMaxContext =
|
||||
entry.usage?.maxContext !== undefined ? entry.usage.maxContext : 32768;
|
||||
const actorUsedTokens = entry.usage?.inputTokens || 0;
|
||||
const actorUsagePctOfContext =
|
||||
actorMaxContext > 0 ? (actorUsedTokens / actorMaxContext) * 100 : 0;
|
||||
const isActorAbsolute = actorMaxContext > 0 && actorUsagePctOfContext >= 20;
|
||||
|
||||
const scaledActorBreakdown = actorBreakdown
|
||||
? actorBreakdown.map((item) => ({
|
||||
...item,
|
||||
pct: isActorAbsolute
|
||||
? item.relativePct * (actorUsedTokens / actorMaxContext)
|
||||
: item.relativePct,
|
||||
}))
|
||||
: null;
|
||||
|
||||
const decoderMaxContext =
|
||||
entry.decoderUsage?.maxContext !== undefined
|
||||
? entry.decoderUsage.maxContext
|
||||
: 32768;
|
||||
const decoderUsedTokens = entry.decoderUsage?.inputTokens || 0;
|
||||
const decoderUsagePctOfContext =
|
||||
decoderMaxContext > 0 ? (decoderUsedTokens / decoderMaxContext) * 100 : 0;
|
||||
const isDecoderAbsolute =
|
||||
decoderMaxContext > 0 && decoderUsagePctOfContext >= 20;
|
||||
|
||||
const scaledDecoderBreakdown = decoderBreakdown
|
||||
? decoderBreakdown.map((item) => ({
|
||||
...item,
|
||||
pct: isDecoderAbsolute
|
||||
? item.relativePct * (decoderUsedTokens / decoderMaxContext)
|
||||
: item.relativePct,
|
||||
}))
|
||||
: null;
|
||||
const [activeTab, setActiveTab] = useState<string>("actor");
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry.rawPrompt && entry.decoderPrompt) {
|
||||
@@ -196,9 +26,47 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
// Helper to resolve components with a fallback if none exist (for backwards-compatibility)
|
||||
const getComponents = (
|
||||
promptBreakdown: PromptBreakdown | null | undefined,
|
||||
defaultType: "world" | "input",
|
||||
) => {
|
||||
if (!promptBreakdown) return [];
|
||||
if (promptBreakdown.components && promptBreakdown.components.length > 0) {
|
||||
return promptBreakdown.components;
|
||||
}
|
||||
// Fallback: convert flat strings into components list
|
||||
return [
|
||||
{
|
||||
label: "System Prompt",
|
||||
type: "system" as const,
|
||||
content: promptBreakdown.systemPrompt || "",
|
||||
},
|
||||
{
|
||||
label: "User Context",
|
||||
type: defaultType,
|
||||
content: promptBreakdown.userContext || "",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const actorComponents = getComponents(entry.rawPrompt, "world");
|
||||
const decoderComponents = getComponents(entry.decoderPrompt, "input");
|
||||
|
||||
const isValidatorTab = activeTab.startsWith("validator-");
|
||||
const validatorIndex = isValidatorTab
|
||||
? parseInt(activeTab.substring("validator-".length), 10)
|
||||
: -1;
|
||||
const validatorCall = isValidatorTab
|
||||
? entry.validatorCalls?.find((c) => c.intentIndex === validatorIndex)
|
||||
: null;
|
||||
const validatorComponents = validatorCall
|
||||
? getComponents(validatorCall.prompt, "world")
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[750px] sm:max-w-[750px] h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogContent className="max-w-187.5 sm:max-w-187.5 h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-5 pb-4 border-b">
|
||||
<DialogTitle className="text-lg">
|
||||
Raw Prompts & Token Usage ({entry.entityName})
|
||||
@@ -210,244 +78,78 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
onTabChange={setActiveTab}
|
||||
hasActor={!!entry.rawPrompt}
|
||||
hasDecoder={!!entry.decoderPrompt}
|
||||
validatorCalls={
|
||||
entry.validatorCalls?.map((c) => ({
|
||||
intentIndex: c.intentIndex,
|
||||
intentContent: c.intentContent,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-5">
|
||||
{activeTab === "actor" && entry.rawPrompt && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entry.usage ? (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong>{" "}
|
||||
<span>{entry.usage.providerInstanceName || "Default"}</span>
|
||||
{entry.usage.modelName && (
|
||||
<span> ({entry.usage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm italic text-muted-foreground">
|
||||
No LLM token usage (Player turn used fixed prose).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scaledActorBreakdown && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">
|
||||
Input Prompt Breakdown
|
||||
</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{actorUsedTokens}</strong>
|
||||
{actorMaxContext > 0 ? (
|
||||
<span>
|
||||
{" "}
|
||||
/ {actorMaxContext} (
|
||||
{actorUsagePctOfContext.toFixed(1)}% used)
|
||||
</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{scaledActorBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
actorMaxContext > 0
|
||||
? (item.tokens / actorMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isActorAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - actorUsagePctOfContext}%` }}
|
||||
title={`Available: ${actorMaxContext - actorUsedTokens} tokens (${(100 - actorUsagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Accordion type="multiple">
|
||||
{scaledActorBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
actorMaxContext > 0
|
||||
? (item.tokens / actorMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm">
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
/>
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens
|
||||
({displayPct.toFixed(0)}%)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.usage && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="font-semibold">LLM Output</span>
|
||||
<span>
|
||||
Total Output Tokens:{" "}
|
||||
<strong>{entry.usage.outputTokens}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{entry.narrativeProse}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PromptAnalyzer
|
||||
components={actorComponents}
|
||||
inputTokens={entry.usage?.inputTokens || 0}
|
||||
maxContext={
|
||||
entry.usage?.maxContext !== undefined
|
||||
? entry.usage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={entry.usage?.modelName}
|
||||
providerInstanceName={entry.usage?.providerInstanceName}
|
||||
outputLabel="LLM Output (Narrative Prose)"
|
||||
outputText={entry.narrativeProse}
|
||||
outputTokens={entry.usage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "decoder" && entry.decoderPrompt && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entry.decoderUsage && (
|
||||
<div className="rounded border-2 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<strong>LLM Instance:</strong>{" "}
|
||||
<span>
|
||||
{entry.decoderUsage.providerInstanceName || "Default"}
|
||||
</span>
|
||||
{entry.decoderUsage.modelName && (
|
||||
<span> ({entry.decoderUsage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
<PromptAnalyzer
|
||||
components={decoderComponents}
|
||||
inputTokens={entry.decoderUsage?.inputTokens || 0}
|
||||
maxContext={
|
||||
entry.decoderUsage?.maxContext !== undefined
|
||||
? entry.decoderUsage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={entry.decoderUsage?.modelName}
|
||||
providerInstanceName={entry.decoderUsage?.providerInstanceName}
|
||||
outputLabel="LLM Output (Decoded Intent Sequence)"
|
||||
outputText={JSON.stringify(
|
||||
entry.decodedIntents || entry.intents,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
outputTokens={entry.decoderUsage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scaledDecoderBreakdown && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-1">
|
||||
<span className="font-semibold">
|
||||
Input Prompt Breakdown
|
||||
</span>
|
||||
<span>
|
||||
Total Input Tokens: <strong>{decoderUsedTokens}</strong>
|
||||
{decoderMaxContext > 0 ? (
|
||||
<span>
|
||||
{" "}
|
||||
/ {decoderMaxContext} (
|
||||
{decoderUsagePctOfContext.toFixed(1)}% used)
|
||||
</span>
|
||||
) : (
|
||||
<span> (infinite context)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-6 w-full rounded border overflow-hidden bg-muted shadow-inner mb-2">
|
||||
{scaledDecoderBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
decoderMaxContext > 0
|
||||
? (item.tokens / decoderMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-full transition-all duration-300 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${displayPct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isDecoderAbsolute && (
|
||||
<div
|
||||
className="bg-white h-full"
|
||||
style={{ width: `${100 - decoderUsagePctOfContext}%` }}
|
||||
title={`Available: ${decoderMaxContext - decoderUsedTokens} tokens (${(100 - decoderUsagePctOfContext).toFixed(1)}% remaining)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Accordion type="multiple">
|
||||
{scaledDecoderBreakdown.map((item, idx) => {
|
||||
const displayPct =
|
||||
decoderMaxContext > 0
|
||||
? (item.tokens / decoderMaxContext) * 100
|
||||
: item.relativePct;
|
||||
return (
|
||||
<AccordionItem key={idx} value={String(idx)}>
|
||||
<AccordionTrigger className="text-sm">
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-sm mr-2 ${
|
||||
item.type === "system"
|
||||
? "bg-blue-500"
|
||||
: item.type === "world"
|
||||
? "bg-emerald-500"
|
||||
: item.type === "memories"
|
||||
? "bg-purple-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
/>
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens
|
||||
({displayPct.toFixed(0)}%)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="m-0 p-2 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{item.content}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
{validatorCall && validatorCall.prompt && (
|
||||
<PromptAnalyzer
|
||||
components={validatorComponents}
|
||||
inputTokens={validatorCall.usage?.inputTokens || 0}
|
||||
maxContext={
|
||||
validatorCall.usage?.maxContext !== undefined
|
||||
? validatorCall.usage.maxContext
|
||||
: 32768
|
||||
}
|
||||
modelName={validatorCall.usage?.modelName}
|
||||
providerInstanceName={validatorCall.usage?.providerInstanceName}
|
||||
outputLabel={`LLM Output`}
|
||||
outputText={JSON.stringify(validatorCall.response, null, 2)}
|
||||
outputTokens={validatorCall.usage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{entry.decoderUsage && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="font-semibold">LLM Output</span>
|
||||
<span>
|
||||
Total Output Tokens:{" "}
|
||||
<strong>{entry.decoderUsage.outputTokens}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border-2">
|
||||
<pre className="m-0 p-2 bg-muted text-xs font-mono whitespace-pre-wrap text-foreground">
|
||||
{JSON.stringify(entry.intents, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{validatorCall && !validatorCall.prompt && (
|
||||
<div className="flex flex-col items-center justify-center border border-dashed rounded-lg bg-muted/20 text-muted-foreground p-8 my-6">
|
||||
<span className="text-sm font-semibold mb-2 text-foreground">
|
||||
Bypassed LLM Validation
|
||||
</span>
|
||||
<p className="text-xs text-center text-muted-foreground max-w-md">
|
||||
{validatorCall.response.reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
interface PromptSwitcherProps {
|
||||
activeTab: "actor" | "decoder";
|
||||
onTabChange: (tab: "actor" | "decoder") => void;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
hasActor: boolean;
|
||||
hasDecoder: boolean;
|
||||
validatorCalls?: { intentIndex: number; intentContent: string }[];
|
||||
}
|
||||
|
||||
export function PromptSwitcher({
|
||||
@@ -12,32 +13,67 @@ export function PromptSwitcher({
|
||||
onTabChange,
|
||||
hasActor,
|
||||
hasDecoder,
|
||||
validatorCalls = [],
|
||||
}: PromptSwitcherProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 border-b bg-muted/50 px-5 py-4">
|
||||
<button
|
||||
onClick={() => onTabChange("actor")}
|
||||
disabled={!hasActor}
|
||||
className={`flex h-14 w-40 items-center justify-center border-2 text-sm font-medium transition-all ${
|
||||
activeTab === "actor"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/30 bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
Actor Prompt
|
||||
</button>
|
||||
<span className="text-xl text-muted-foreground">→</span>
|
||||
<button
|
||||
onClick={() => onTabChange("decoder")}
|
||||
disabled={!hasDecoder}
|
||||
className={`flex h-14 w-44 items-center justify-center border-2 text-sm font-medium transition-all ${
|
||||
activeTab === "decoder"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/30 bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
Intent Decoder
|
||||
</button>
|
||||
<div className="flex items-center justify-center gap-4 border-b bg-muted/40 px-6 py-5 overflow-x-auto">
|
||||
{/* Primary Pipeline (Linear flow to the left) */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => onTabChange("actor")}
|
||||
disabled={!hasActor}
|
||||
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all ${
|
||||
activeTab === "actor"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40 rounded`}
|
||||
>
|
||||
Actor Prompt
|
||||
</button>
|
||||
|
||||
<span className="text-lg text-muted-foreground">→</span>
|
||||
|
||||
<button
|
||||
onClick={() => onTabChange("decoder")}
|
||||
disabled={!hasDecoder}
|
||||
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all ${
|
||||
activeTab === "decoder"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border bg-card text-foreground hover:border-primary/50"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40 rounded`}
|
||||
>
|
||||
Intent Decoder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branching Validator Column to the right of Intent Decoder */}
|
||||
{validatorCalls.length > 0 && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-lg text-muted-foreground">→</span>
|
||||
|
||||
<div className="flex flex-col gap-2 pl-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{validatorCalls.map((call) => {
|
||||
const tabKey = `validator-${call.intentIndex}`;
|
||||
return (
|
||||
<button
|
||||
key={tabKey}
|
||||
onClick={() => onTabChange(tabKey)}
|
||||
className={`flex h-12 w-36 items-center justify-center border-2 text-xs font-semibold uppercase tracking-wider transition-all rounded ${
|
||||
activeTab === tabKey
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border bg-card text-foreground hover:border-primary/50"
|
||||
}`}
|
||||
title={call.intentContent}
|
||||
>
|
||||
LLM Validator (Intent #{call.intentIndex})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
62
apps/gui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded border border-border/30 bg-card p-4 text-sm shadow-[2px_2px_0_0_var(--border)] flex flex-col md:flex-row md:items-center gap-3 justify-between",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"font-head font-bold leading-none tracking-tight text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground mt-1 flex-1 leading-relaxed md:mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
const AlertAction = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("shrink-0 flex items-center mt-2 md:mt-0 md:ml-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertAction.displayName = "AlertAction";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction };
|
||||
@@ -22,6 +22,7 @@ const buttonVariants = cva(
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3 text-xs",
|
||||
xs: "h-7 px-2.5 text-xs",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
|
||||
@@ -99,12 +99,13 @@ function InputGroupButton({
|
||||
return React.cloneElement(render, {
|
||||
className: cn(
|
||||
inputGroupButtonVariants({ size }),
|
||||
(render.props as any)?.className,
|
||||
(render.props as Record<string, unknown>)?.className as
|
||||
string | undefined,
|
||||
className,
|
||||
),
|
||||
type,
|
||||
...props,
|
||||
} as any);
|
||||
} as Record<string, unknown> as React.HTMLAttributes<HTMLElement>);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,71 +1,11 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
selfDescription?: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
minutesToAdvance?: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
turn: number;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
narrativeProse: string;
|
||||
intents: IntentInfo[];
|
||||
timestamp: string;
|
||||
rawPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
decoderUsage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
isAgent: boolean;
|
||||
}
|
||||
|
||||
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,71 +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 long-term memory).
|
||||
*/
|
||||
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") {
|
||||
await handoffEngine.runHandoff(
|
||||
entity,
|
||||
bufferEntries,
|
||||
worldState.clock.get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,471 +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,
|
||||
): 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: 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
turn: session.turn,
|
||||
maxTurns: session.maxTurns,
|
||||
scenarioName: session.scenarioName,
|
||||
scenarioDescription: session.scenarioDescription,
|
||||
entities: session.entities,
|
||||
log: session.log,
|
||||
entityIndex: session.entityIndex,
|
||||
waitingEntity: session.waitingEntity,
|
||||
error: session.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,277 +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<IntentInfo[]> {
|
||||
const intentInfos: IntentInfo[] = [];
|
||||
|
||||
for (const intent of intents) {
|
||||
const outcome = await session.architect.processIntent(worldState, intent);
|
||||
const ts = worldState.clock.get().toISOString();
|
||||
|
||||
intentInfos.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(),
|
||||
};
|
||||
|
||||
if (
|
||||
session.actorProvider.lastCalls &&
|
||||
session.actorProvider.lastCalls.length > 0
|
||||
) {
|
||||
const actorCall =
|
||||
session.actorProvider.lastCalls[
|
||||
session.actorProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.rawPrompt = {
|
||||
systemPrompt: actorCall.systemPrompt,
|
||||
userContext: actorCall.userContext,
|
||||
};
|
||||
entry.usage = actorCall.usage;
|
||||
}
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const decoderCall =
|
||||
session.decoderProvider.lastCalls[
|
||||
session.decoderProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: decoderCall.systemPrompt,
|
||||
userContext: decoderCall.userContext,
|
||||
};
|
||||
entry.decoderUsage = decoderCall.usage;
|
||||
}
|
||||
|
||||
entry.intents = await processIntents(
|
||||
result.intents.intents,
|
||||
info.id,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
|
||||
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: ctx.systemPrompt,
|
||||
userContext: ctx.userContext,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
session.decoderProvider.lastCalls &&
|
||||
session.decoderProvider.lastCalls.length > 0
|
||||
) {
|
||||
const call =
|
||||
session.decoderProvider.lastCalls[
|
||||
session.decoderProvider.lastCalls.length - 1
|
||||
];
|
||||
entry.decoderPrompt = {
|
||||
systemPrompt: call.systemPrompt,
|
||||
userContext: call.userContext,
|
||||
};
|
||||
entry.decoderUsage = call.usage;
|
||||
}
|
||||
|
||||
entry.intents = await processIntents(
|
||||
result.intents.intents,
|
||||
ctx.entityId,
|
||||
entity,
|
||||
worldState,
|
||||
session,
|
||||
);
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,16 +72,37 @@
|
||||
],
|
||||
"initialMemories": [
|
||||
{
|
||||
"id": "alpha-wake",
|
||||
"timestamp": "2026-07-09T07:58:00.000Z",
|
||||
"id": "ab3f29d2-cf11-4111-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-01T07:58:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "monologue",
|
||||
"originalText": "I didn't have a choice. I would have been sent to jail if I hadn't agreed to do this experiement.",
|
||||
"description": "",
|
||||
"content": "I didn't have a choice. I would have been sent to jail if I hadn't agreed to do this experiment.",
|
||||
"actorId": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
|
||||
"targetIds": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "10ak29d2-as11-9811-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-09T06:00:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "action",
|
||||
"content": "entity@7c9b83b3-8cfb-4e89-8d77-626a5757d591[I] woke up today in the room and saw entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[another man]!",
|
||||
"actorId": "7c9b83b3-8cfb-4e89-8d77-626a5757d591",
|
||||
"targetIds": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zz3f29d2-as11-9811-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-09T07:58:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "action",
|
||||
"content": "entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[I] wake up from my sleep.",
|
||||
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
|
||||
"targetIds": []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -119,13 +140,12 @@
|
||||
],
|
||||
"initialMemories": [
|
||||
{
|
||||
"id": "beta-wake",
|
||||
"timestamp": "2026-07-09T07:58:30.000Z",
|
||||
"id": "zx1f29d2-cf11-4111-9a99-b13c126d123e",
|
||||
"timestamp": "2026-07-09T07:58:00.000Z",
|
||||
"locationId": "white-room",
|
||||
"intent": {
|
||||
"type": "action",
|
||||
"originalText": "Why can't I remember anything before the research agreement. It's like my memory was erased.",
|
||||
"description": "",
|
||||
"content": "entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[I] wake up in an unfamiliar place.",
|
||||
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
|
||||
"targetIds": []
|
||||
}
|
||||
|
||||
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
@@ -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"
|
||||
}
|
||||
},
|
||||
@@ -56,6 +56,7 @@
|
||||
"@langchain/openai": "^0.3.17",
|
||||
"@langchain/openrouter": "^0.4.3",
|
||||
"@types/node": "^20.19.43",
|
||||
"compromise": "^14.16.0",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
WorldState,
|
||||
naturalizeTime,
|
||||
serializeSubjectiveWorldState,
|
||||
resolveAlias,
|
||||
} from "@omnia/core";
|
||||
import {
|
||||
BufferEntry,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
LedgerEntry,
|
||||
LedgerRepository,
|
||||
} from "@omnia/memory";
|
||||
import { hydrate } from "@omnia/voice";
|
||||
import { PromptComponent, IPromptBuilder, PromptBreakdown } from "@omnia/llm";
|
||||
|
||||
/**
|
||||
* Zod schema for the structured response expected from the actor LLM.
|
||||
@@ -33,17 +34,19 @@ export type ActorResponse = z.infer<typeof ActorResponseSchema>;
|
||||
*
|
||||
* The prompt is strictly epistemically bounded: the entity only sees what
|
||||
* it is allowed to see (public attributes + private attributes explicitly
|
||||
* ACL'd to it), its own recent memory buffer, and the entities co-located
|
||||
* ACL'd to it), its own Cognitive Buffer, and the entities co-located
|
||||
* with it. System UUIDs are surfaced as subjective aliases.
|
||||
*/
|
||||
export class ActorPromptBuilder {
|
||||
export class ActorPromptBuilder implements IPromptBuilder<
|
||||
[WorldState, Entity]
|
||||
> {
|
||||
/**
|
||||
* @param bufferRepo Used to fetch the actor's recent memory. Optional —
|
||||
* @param bufferRepo Used to fetch the actor's Cognitive Buffer. Optional —
|
||||
* if absent, the memory section is omitted.
|
||||
* @param ledgerRepo Used to fetch long-term memories. Optional.
|
||||
* @param memoryLimit Maximum number of recent buffer entries to inject.
|
||||
* @param ledgerRepo Used to fetch Memory Ledger entries. Optional.
|
||||
* @param memoryLimit Maximum number of recent Cognitive Buffer entries to inject.
|
||||
* Defaults to 20.
|
||||
* @param ledgerLimit Maximum number of long-term memories to retrieve.
|
||||
* @param ledgerLimit Maximum number of Memory Ledger entries to retrieve.
|
||||
* Defaults to 5.
|
||||
*/
|
||||
constructor(
|
||||
@@ -56,13 +59,20 @@ export class ActorPromptBuilder {
|
||||
/**
|
||||
* Assembles the system prompt and user context for a given entity.
|
||||
*/
|
||||
build(
|
||||
worldState: WorldState,
|
||||
entity: Entity,
|
||||
): { systemPrompt: string; userContext: string } {
|
||||
/**
|
||||
* Assembles the system prompt and user context for a given entity.
|
||||
*/
|
||||
/**
|
||||
* Assembles the system prompt and user context for a given entity.
|
||||
*/
|
||||
build(worldState: WorldState, entity: Entity): PromptBreakdown {
|
||||
const systemPrompt = this.buildSystemPrompt();
|
||||
const userContext = this.buildUserContext(worldState, entity);
|
||||
return { systemPrompt, userContext };
|
||||
const { userContext, components } = this.buildUserContext(
|
||||
worldState,
|
||||
entity,
|
||||
systemPrompt,
|
||||
);
|
||||
return { systemPrompt, userContext, components };
|
||||
}
|
||||
|
||||
private buildSystemPrompt(): string {
|
||||
@@ -76,30 +86,32 @@ Your output is a short block of narrative prose describing what your character d
|
||||
|
||||
Guidelines:
|
||||
- Always write in the first person
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment or your surroundings, or other characters' actions.
|
||||
- Refer to other entities by the subjective names/aliases that you refer to them as.
|
||||
- Keep your prose vivid but concise. Write it in natural narrative order.
|
||||
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
|
||||
- Never speak or act on another entity's behalf. You only control your own character.
|
||||
- Stay strictly within what your character knows. Do not invent knowledge that doesn't exist or act on it.
|
||||
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying.
|
||||
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate the environment or your surroundings, or other characters' actions.
|
||||
- Be clear about who or what you are interacting with.
|
||||
".
|
||||
`.trim();
|
||||
}
|
||||
|
||||
private buildUserContext(worldState: WorldState, entity: Entity): string {
|
||||
const sections: string[] = [];
|
||||
private buildUserContext(
|
||||
worldState: WorldState,
|
||||
entity: Entity,
|
||||
systemPrompt: string,
|
||||
): {
|
||||
userContext: string;
|
||||
components: PromptComponent[];
|
||||
} {
|
||||
const now = worldState.clock.get();
|
||||
|
||||
// --- Subjective present time ---
|
||||
sections.push(
|
||||
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
|
||||
);
|
||||
|
||||
// --- Subjective world state (self + perceived entities + co-location) ---
|
||||
sections.push(
|
||||
`=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`,
|
||||
);
|
||||
// --- Subjective present time & world state ---
|
||||
const momentStr = `=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`;
|
||||
const perceivedStr = `=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`;
|
||||
const worldInfo = `${momentStr}\n\n${perceivedStr}`;
|
||||
|
||||
// Fetch recent buffer entries once
|
||||
let recentEntries: BufferEntry[] = [];
|
||||
@@ -111,27 +123,55 @@ Guidelines:
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recent memory ---
|
||||
const memorySection = this.buildMemorySection(entity, recentEntries, now);
|
||||
if (memorySection) {
|
||||
sections.push(memorySection);
|
||||
}
|
||||
|
||||
// --- Recalled Long-Term memory ---
|
||||
// --- Recalled Memory Ledger ---
|
||||
const ledgerSection = this.buildLedgerSection(
|
||||
worldState,
|
||||
entity,
|
||||
recentEntries,
|
||||
now,
|
||||
);
|
||||
if (ledgerSection) {
|
||||
sections.push(ledgerSection);
|
||||
const memoryLedger = ledgerSection || "";
|
||||
|
||||
// --- Cognitive Buffer ---
|
||||
const memorySection = this.buildCognitiveBufferSection(
|
||||
entity,
|
||||
recentEntries,
|
||||
now,
|
||||
);
|
||||
const cognitiveBuffer = memorySection || "";
|
||||
|
||||
// Assemble final user context
|
||||
const parts: string[] = [worldInfo];
|
||||
if (memoryLedger) parts.push(memoryLedger);
|
||||
if (cognitiveBuffer) parts.push(cognitiveBuffer);
|
||||
const userContext = parts.join("\n\n");
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "World Info", type: "world", content: worldInfo },
|
||||
];
|
||||
if (memoryLedger) {
|
||||
components.push({
|
||||
label: "Memory Ledger",
|
||||
type: "memories",
|
||||
content: memoryLedger,
|
||||
});
|
||||
}
|
||||
if (cognitiveBuffer) {
|
||||
components.push({
|
||||
label: "Cognitive Buffer",
|
||||
type: "events",
|
||||
content: cognitiveBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
return {
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
private buildMemorySection(
|
||||
private buildCognitiveBufferSection(
|
||||
entity: Entity,
|
||||
entries: BufferEntry[],
|
||||
now: Date,
|
||||
@@ -139,7 +179,7 @@ Guidelines:
|
||||
if (!this.bufferRepo) return null;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return `=== RECENT EVENTS ===\n(No recent events recorded.)`;
|
||||
return `=== COGNITIVE BUFFER ===\n(No entries recorded.)`;
|
||||
}
|
||||
|
||||
const recent = entries.slice(-this.memoryLimit);
|
||||
@@ -147,9 +187,16 @@ Guidelines:
|
||||
let currentGroup: string | null = null;
|
||||
|
||||
for (const entry of recent) {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
let serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
if (
|
||||
entry.intent.actorId === entity.id &&
|
||||
entry.intent.type === "dialogue"
|
||||
) {
|
||||
serialized = `I said: ${serialized}`;
|
||||
}
|
||||
|
||||
if (when !== currentGroup) {
|
||||
currentGroup = when;
|
||||
const header = when.charAt(0).toUpperCase() + when.slice(1);
|
||||
@@ -159,7 +206,7 @@ Guidelines:
|
||||
groupedLines.push(` - ${serialized}`);
|
||||
}
|
||||
|
||||
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`;
|
||||
return `=== COGNITIVE BUFFER ===\n${groupedLines.join("\n")}`;
|
||||
}
|
||||
|
||||
private buildLedgerSection(
|
||||
@@ -239,12 +286,7 @@ Guidelines:
|
||||
for (const entry of recalled) {
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
let content = entry.content;
|
||||
// Resolve system IDs to subjective aliases in the content
|
||||
for (const targetId of entry.involvedEntityIds) {
|
||||
const alias = resolveAlias(entity, targetId);
|
||||
content = content.replace(new RegExp(targetId, "g"), alias);
|
||||
}
|
||||
let content = hydrate(entry.content, entity);
|
||||
if (entry.locationId) {
|
||||
content += ` (at ${entry.locationId})`;
|
||||
}
|
||||
@@ -263,6 +305,6 @@ Guidelines:
|
||||
}
|
||||
}
|
||||
|
||||
return `=== YOUR MEMORIES ===\n${groupedLines.join("\n")}`;
|
||||
return `=== MEMORY LEDGER ===\n${groupedLines.join("\n")}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Entity, WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { ILLMProvider, PromptComponent } from "@omnia/llm";
|
||||
import { BufferEntry, BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { Intent, IntentDecoder, IntentSequence } from "@omnia/intent";
|
||||
import {
|
||||
@@ -54,6 +54,9 @@ export interface ActorTurnResult {
|
||||
narrativeProse: string;
|
||||
/** The decoded intent sequence (split/classified from the prose). */
|
||||
intents: IntentSequence;
|
||||
systemPrompt?: string;
|
||||
userContext?: string;
|
||||
promptComponents?: PromptComponent[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +79,7 @@ export class ActorAgent {
|
||||
|
||||
constructor(
|
||||
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
|
||||
bufferRepo?: BufferRepository,
|
||||
private bufferRepo?: BufferRepository,
|
||||
ledgerRepo?: LedgerRepository,
|
||||
memoryLimit?: number,
|
||||
generator?: IActorProseGenerator,
|
||||
@@ -116,7 +119,7 @@ export class ActorAgent {
|
||||
);
|
||||
}
|
||||
|
||||
const { systemPrompt, userContext } = this.promptBuilder.build(
|
||||
const { systemPrompt, userContext, components } = this.promptBuilder.build(
|
||||
worldState,
|
||||
entity,
|
||||
);
|
||||
@@ -127,15 +130,27 @@ export class ActorAgent {
|
||||
userContext,
|
||||
);
|
||||
|
||||
const recentEntries = this.bufferRepo
|
||||
? this.bufferRepo.listForOwner(entity.id)
|
||||
: [];
|
||||
const recentIntents = recentEntries
|
||||
.filter((e) => e.intent.actorId !== entity.id)
|
||||
.slice(-3)
|
||||
.map((e) => e.intent);
|
||||
|
||||
const intents = await this.decoder.decode(
|
||||
worldState,
|
||||
entity.id,
|
||||
narrativeProse,
|
||||
recentIntents,
|
||||
);
|
||||
|
||||
return {
|
||||
narrativeProse,
|
||||
intents,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
promptComponents: components,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WorldState, Entity } from "@omnia/core";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { ActorPromptBuilder } from "../src/actor-prompt-builder";
|
||||
|
||||
describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
describe("ActorPromptBuilder with Memory Ledger Integration", () => {
|
||||
let db: Database.Database;
|
||||
let bufferRepo: BufferRepository;
|
||||
let ledgerRepo: LedgerRepository;
|
||||
@@ -31,7 +31,7 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("should inject both recent memory and recalled long-term memory with subjective aliases resolved", () => {
|
||||
it("should inject both Cognitive Buffer and recalled Memory Ledger entries with subjective aliases resolved", () => {
|
||||
const world = new WorldState(
|
||||
"world-123",
|
||||
new Date("2024-01-10T12:00:00.000Z"),
|
||||
@@ -55,19 +55,19 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
type: "dialogue",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
originalText: "Hello there",
|
||||
description: "Alice greets Bob",
|
||||
content: "entity@alice[I] say 'Hello there' to entity@bob[Bob]",
|
||||
modifiers: [],
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Populate ledger repository (long-term memory)
|
||||
// 2. Populate ledger repository (Memory Ledger)
|
||||
ledgerRepo.save({
|
||||
id: "ledger1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-08T12:00:00.000Z", // 2 days ago
|
||||
locationId: "tavern",
|
||||
involvedEntityIds: ["bob"],
|
||||
content: "alice met bob at the tavern.",
|
||||
content: "entity@alice[Alice] met entity@bob[bob] at the tavern.",
|
||||
quotes: ["I am a ranger."],
|
||||
importance: 9,
|
||||
embedding: [],
|
||||
@@ -76,14 +76,14 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
|
||||
const { userContext } = builder.build(world, alice);
|
||||
|
||||
// Check recent memory exists
|
||||
expect(userContext).toContain("=== RECENT EVENTS ===");
|
||||
expect(userContext).toContain("Alice greets Bob");
|
||||
// Check Cognitive Buffer exists
|
||||
expect(userContext).toContain("=== COGNITIVE BUFFER ===");
|
||||
expect(userContext).toContain("I said: I say 'Hello there' to Strider");
|
||||
|
||||
// Check long-term memory exists
|
||||
expect(userContext).toContain("=== YOUR MEMORIES ===");
|
||||
// Bob should be resolved to Strider in the ledger content
|
||||
expect(userContext).toContain("alice met Strider at the tavern.");
|
||||
// Check Memory Ledger exists
|
||||
expect(userContext).toContain("=== MEMORY LEDGER ===");
|
||||
// Bob should be resolved to Strider, and alice to I in the ledger content
|
||||
expect(userContext).toContain("I met Strider at the tavern.");
|
||||
expect(userContext).toContain('Quote: "I am a ranger."');
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
|
||||
const { userContext } = builder.build(world, alice);
|
||||
|
||||
expect(userContext).toContain("=== RECENT EVENTS ===");
|
||||
expect(userContext).not.toContain("=== YOUR MEMORIES ===");
|
||||
expect(userContext).toContain("=== COGNITIVE BUFFER ===");
|
||||
expect(userContext).not.toContain("=== MEMORY LEDGER ===");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../memory" }
|
||||
{ "path": "../memory" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ProcessResult extends ValidationResult {
|
||||
}
|
||||
|
||||
export class Architect {
|
||||
private validator: LLMValidator;
|
||||
public validator: LLMValidator;
|
||||
private timeDeltaGenerator: TimeDeltaGenerator;
|
||||
|
||||
constructor(
|
||||
@@ -47,22 +47,22 @@ export class Architect {
|
||||
* Processes, validates, generates deltas, applies them to the world state,
|
||||
* and persists the changes to the database.
|
||||
*
|
||||
* "monologue" intents are internal thoughts — they bypass validation and
|
||||
* "monologue" and "thought" intents are internal thoughts — they bypass validation and
|
||||
* time-delta generation entirely: the clock does not advance, the world
|
||||
* state is not mutated or persisted. The caller is responsible for writing
|
||||
* the monologue to the actor's memory buffer.
|
||||
* the monologue/thought to the actor's Cognitive Buffer.
|
||||
*/
|
||||
async processIntent(
|
||||
worldState: WorldState,
|
||||
intent: Intent,
|
||||
): Promise<ProcessResult> {
|
||||
// 0. Monologue intents are purely internal — short-circuit before any
|
||||
// 0. Monologue/thought intents are purely internal — short-circuit before any
|
||||
// validation or world mutation.
|
||||
if (intent.type === "monologue") {
|
||||
if (intent.type === "monologue" || intent.type === "thought") {
|
||||
return {
|
||||
isValid: true,
|
||||
reason:
|
||||
"Monologue intent bypasses validation (internal thought, not perceivable).",
|
||||
"Monologue/thought intent bypasses validation (internal thought, not perceivable).",
|
||||
timeDelta: {
|
||||
minutesToAdvance: 0,
|
||||
explanation: "Internal thought — no time elapsed.",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { hydrateObjective } from "@omnia/voice";
|
||||
|
||||
export const TimeDeltaSchema = z.object({
|
||||
minutesToAdvance: z.number().int().nonnegative(),
|
||||
@@ -26,10 +27,11 @@ export class TimeDeltaGenerator implements IDeltaGenerator<TimeDelta> {
|
||||
explanation: "Dialogue action; 1 minute granted for quick exchange.",
|
||||
};
|
||||
}
|
||||
if (intent.type === "monologue") {
|
||||
if (intent.type === "monologue" || intent.type === "thought") {
|
||||
return {
|
||||
minutesToAdvance: 0,
|
||||
explanation: "Monologue action; no time advanced for internal thought.",
|
||||
explanation:
|
||||
"Monologue/thought action; no time advanced for internal thought.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +47,8 @@ Return a structured JSON object containing:
|
||||
- "explanation": a brief explanation of why this amount of time is appropriate.
|
||||
`.trim();
|
||||
|
||||
const objectiveContent = hydrateObjective(intent.content, worldState);
|
||||
|
||||
const userContext = `
|
||||
=== CURRENT WORLD STATE ===
|
||||
Current Time: ${worldState.clock.get().toISOString()}
|
||||
@@ -54,8 +58,7 @@ ${serializeObjectiveWorldState(worldState)}
|
||||
=== ACTION ===
|
||||
Actor ID: ${intent.actorId}
|
||||
Type: ${intent.type}
|
||||
Description: "${intent.description}"
|
||||
Original Text: "${intent.originalText}"
|
||||
Content: "${objectiveContent}"
|
||||
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
|
||||
`.trim();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./llm-validator.js";
|
||||
export * from "./llm-validator-prompt-builder.js";
|
||||
export * from "./architect.js";
|
||||
export * from "./delta.js";
|
||||
|
||||
59
packages/architect/src/llm-validator-prompt-builder.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { hydrateObjective } from "@omnia/voice";
|
||||
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
|
||||
|
||||
/**
|
||||
* Prompt builder for the LLM Validator (World Architect).
|
||||
* Separates prompt generation, structure, and component breakdowns.
|
||||
*/
|
||||
export class LLMValidatorPromptBuilder implements IPromptBuilder<
|
||||
[WorldState, Intent]
|
||||
> {
|
||||
build(worldState: WorldState, intent: Intent): PromptBreakdown {
|
||||
const serializedWorld = serializeObjectiveWorldState(worldState);
|
||||
|
||||
const systemPrompt = `
|
||||
You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game.
|
||||
Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world.
|
||||
Exempt dialogue or speech actions from validation (consider them always valid).
|
||||
Enforce logical boundaries such as:
|
||||
- Spatial boundaries (an actor cannot grab an object in another location unless they are there).
|
||||
- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it).
|
||||
- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so).
|
||||
- State/Attribute constraints.
|
||||
- An actor can perform actions on themselves as long as it follows the boundaries stated above.
|
||||
|
||||
You must respond with a JSON object containing:
|
||||
- "isValid": boolean indicating if the action is possible/allowed.
|
||||
- "reason": a very short explanation of why the action is allowed or denied.
|
||||
`.trim();
|
||||
|
||||
const objectiveContent = hydrateObjective(intent.content, worldState);
|
||||
|
||||
const worldStateSection = `=== CURRENT WORLD STATE ===\nCurrent Time: ${worldState.clock.get().toISOString()}\nEntities & Attributes:\n${serializedWorld}`;
|
||||
const proposedActionSection = `=== PROPOSED ACTION ===\nActor ID: ${intent.actorId}\nType: ${intent.type}\nContent: "${objectiveContent}"\nTarget IDs: ${intent.targetIds.join(", ") || "(None)"}`;
|
||||
|
||||
const userContext = `${worldStateSection}\n\n${proposedActionSection}\n\nDecide if the proposed action is logically valid and physically possible.`;
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{
|
||||
label: "Current World State",
|
||||
type: "world",
|
||||
content: worldStateSection,
|
||||
},
|
||||
{
|
||||
label: "Proposed Action",
|
||||
type: "input",
|
||||
content: proposedActionSection,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider, PromptBreakdown } from "@omnia/llm";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { LLMValidatorPromptBuilder } from "./llm-validator-prompt-builder.js";
|
||||
|
||||
export const ValidationResultSchema = z.object({
|
||||
isValid: z.boolean(),
|
||||
@@ -11,12 +12,17 @@ export const ValidationResultSchema = z.object({
|
||||
export type ValidationResult = z.infer<typeof ValidationResultSchema>;
|
||||
|
||||
export class LLMValidator {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
public lastResult: PromptBreakdown | null = null;
|
||||
private promptBuilder: LLMValidatorPromptBuilder;
|
||||
|
||||
constructor(private llmProvider: ILLMProvider) {
|
||||
this.promptBuilder = new LLMValidatorPromptBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an action intent against the objective world state.
|
||||
*
|
||||
* "monologue" intents must never reach this validator — they are internal
|
||||
* "monologue" and "thought" intents must never reach this validator — they are internal
|
||||
* thoughts that bypass validation entirely (see Architect.processIntent).
|
||||
* This guard exists as a defensive safeguard.
|
||||
*/
|
||||
@@ -24,12 +30,14 @@ export class LLMValidator {
|
||||
worldState: WorldState,
|
||||
intent: Intent,
|
||||
): Promise<ValidationResult> {
|
||||
// Defensive guard: monologue intents bypass validation.
|
||||
if (intent.type === "monologue") {
|
||||
this.lastResult = null;
|
||||
|
||||
// Defensive guard: monologue and thought intents bypass validation.
|
||||
if (intent.type === "monologue" || intent.type === "thought") {
|
||||
return {
|
||||
isValid: true,
|
||||
reason:
|
||||
"Monologue intents are internal thoughts and bypass validation.",
|
||||
"Monologue/thought intents are internal thoughts and bypass validation.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,40 +49,16 @@ export class LLMValidator {
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Serialize the objective world state for the LLM
|
||||
const serializedWorld = serializeObjectiveWorldState(worldState);
|
||||
const { systemPrompt, userContext, components } = this.promptBuilder.build(
|
||||
worldState,
|
||||
intent,
|
||||
);
|
||||
|
||||
// 2. Build the prompts
|
||||
const systemPrompt = `
|
||||
You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game.
|
||||
Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world.
|
||||
Exempt dialogue or speech actions from validation (consider them always valid).
|
||||
Enforce logical boundaries such as:
|
||||
- Spatial boundaries (an actor cannot grab an object in another location unless they are there).
|
||||
- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it).
|
||||
- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so).
|
||||
- State/Attribute constraints.
|
||||
|
||||
You must respond with a JSON object containing:
|
||||
- "isValid": boolean indicating if the action is possible/allowed.
|
||||
- "reason": a concise explanation of why the action is allowed or denied.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
=== CURRENT WORLD STATE ===
|
||||
Current Time: ${worldState.clock.get().toISOString()}
|
||||
Entities & Attributes:
|
||||
${serializedWorld}
|
||||
|
||||
=== PROPOSED ACTION ===
|
||||
Actor ID: ${intent.actorId}
|
||||
Type: ${intent.type}
|
||||
Description: "${intent.description}"
|
||||
Original Text: "${intent.originalText}"
|
||||
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
|
||||
|
||||
Decide if the proposed action is logically valid and physically possible.
|
||||
`.trim();
|
||||
this.lastResult = {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
|
||||
// structured call via the LLM provider
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
WorldState,
|
||||
Entity,
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
AttributeVisibility,
|
||||
} from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { Architect, AliasDeltaGenerator } from "../src/index.js";
|
||||
|
||||
describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
test("returns valid response when LLM validates intent as successful", async () => {
|
||||
describe("World Architect Validation Tests (Tier 1)", () => {
|
||||
test("returns valid response when LLM confirms the intent", async () => {
|
||||
const world = new WorldState("world-1");
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
// Setup mock LLM response
|
||||
const mockResponse = {
|
||||
isValid: true,
|
||||
reason: "Alice is in the room and the chest is unlocked.",
|
||||
@@ -25,9 +26,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "open the chest and read the scroll",
|
||||
description: "Open the chest and read the scroll",
|
||||
selfDescription: "You open the chest and read the scroll.",
|
||||
content: "entity@alice[I] open the chest and read the scroll",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -56,9 +55,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "unlock the gate and escape",
|
||||
description: "Unlock the gate and escape",
|
||||
selfDescription: "You unlock the gate and escape.",
|
||||
content: "entity@bob[I] unlock the gate and escape",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -79,9 +76,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "haunt the mansion",
|
||||
description: "Haunt the mansion",
|
||||
selfDescription: "You haunt the mansion.",
|
||||
content: "entity@ghost[I] haunt the mansion",
|
||||
actorId: "ghost",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -128,9 +123,7 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "pick the lock of the wooden chest",
|
||||
description: "Pick the lock of the wooden chest",
|
||||
selfDescription: "You pick the lock of the wooden chest.",
|
||||
content: "entity@alice[I] pick the lock of the wooden chest",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -177,9 +170,7 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "run away",
|
||||
description: "Run away",
|
||||
selfDescription: "You run away.",
|
||||
content: "entity@bob[I] run away",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../intent" }
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,34 @@
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
import { dehydrate, expandContractions } from "@omnia/voice";
|
||||
import { Intent, IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
import { IntentDecoderPromptBuilder } from "./intent-prompt-builder.js";
|
||||
|
||||
export class IntentDecoder {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
private promptBuilder: IntentDecoderPromptBuilder;
|
||||
|
||||
constructor(private llmProvider: ILLMProvider) {
|
||||
this.promptBuilder = new IntentDecoderPromptBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes narrative prose into an ordered sequence of structured intents.
|
||||
*
|
||||
* Responsibilities (from docs/intents.md):
|
||||
* - Split prose into multiple intents when applicable.
|
||||
* - Classify each intent as "dialogue", "action", or "monologue".
|
||||
* - Parse narrative text into structured JSON with minimal information loss.
|
||||
* - Contextually resolve receiving parties (targets).
|
||||
*/
|
||||
async decode(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
narrativeProse: string,
|
||||
recentIntents: Intent[] = [],
|
||||
): Promise<IntentSequence> {
|
||||
const entityIds = Array.from(worldState.entities.keys());
|
||||
const processedProse = expandContractions(narrativeProse);
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext =
|
||||
aliasEntries.length > 0
|
||||
? aliasEntries
|
||||
.map(
|
||||
([targetId, alias]) =>
|
||||
`- "${alias}" refers to entity ID: "${targetId}"`,
|
||||
)
|
||||
.join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue/self narration.
|
||||
2. Extract the original text fragment from the prose that corresponds to this intent.
|
||||
3. Populate "description" and "selfDescription":
|
||||
- "description": No subject or name — a bare third-person verb phrase only (e.g. "clears their throat", "shakes their head slowly")
|
||||
- "selfDescription": The same event from the actor's own perspective, second person, complete sentence starting with "You" (e.g. "You clear your throat.", "You shake your head slowly."). This is shown directly in the actor's own memory — it must never say "the actor" or refer to them in the third person.
|
||||
- In case of a dialogue, the description and self Description only stores the exact words said by the entity. (e.g. "I will do that later", "Are you serious right now?")
|
||||
4. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" mapping to resolve any subjective names,or aliases used in the prose to their correct system entity IDs. If no specific target, use an empty array.
|
||||
5. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
=== KNOWN ENTITY IDS ===
|
||||
${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
|
||||
|
||||
=== ACTOR ALIASES ===
|
||||
The actor refers to other entities using these subjective names/aliases:
|
||||
${aliasContext}
|
||||
|
||||
=== WORLD STATE ===
|
||||
${serializeSimplifiedWorldState(worldState)}
|
||||
|
||||
=== ACTOR ===
|
||||
Actor ID: ${actorId}
|
||||
|
||||
=== NARRATIVE PROSE ===
|
||||
${narrativeProse}
|
||||
`.trim();
|
||||
const { systemPrompt, userContext, components } = this.promptBuilder.build(
|
||||
worldState,
|
||||
actorId,
|
||||
processedProse,
|
||||
recentIntents,
|
||||
);
|
||||
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
@@ -81,42 +42,32 @@ ${narrativeProse}
|
||||
);
|
||||
}
|
||||
|
||||
const fullIntents = response.data.intents.map((intent) => ({
|
||||
...intent,
|
||||
actorId,
|
||||
}));
|
||||
const aliasMap: Record<string, string> = {};
|
||||
if (actor) {
|
||||
for (const [targetId, alias] of actor.aliases.entries()) {
|
||||
aliasMap[alias] = targetId;
|
||||
}
|
||||
}
|
||||
|
||||
const fullIntents = response.data.intents.map((intent) => {
|
||||
const dehydrated = dehydrate(
|
||||
intent.content,
|
||||
actorId,
|
||||
intent.targetIds,
|
||||
aliasMap,
|
||||
);
|
||||
return {
|
||||
...intent,
|
||||
content: dehydrated,
|
||||
actorId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
intents: fullIntents,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
promptComponents: components,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSimplifiedWorldState(worldState: WorldState): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("Locations:");
|
||||
if (worldState.locations.size > 0) {
|
||||
for (const loc of worldState.locations.values()) {
|
||||
const parentId = (loc as { parentId?: string | null }).parentId;
|
||||
const parentStr = parentId ? ` (Parent: ${parentId})` : "";
|
||||
lines.push(` - Location [ID: ${loc.id}]${parentStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No locations)");
|
||||
}
|
||||
|
||||
lines.push("Entities:");
|
||||
if (worldState.entities.size > 0) {
|
||||
for (const entity of worldState.entities.values()) {
|
||||
const locStr = entity.locationId
|
||||
? ` (Location: ${entity.locationId})`
|
||||
: "";
|
||||
lines.push(` - Entity [ID: ${entity.id}]${locStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No entities)");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
102
packages/intent/src/intent-prompt-builder.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { WorldState, resolveAlias } from "@omnia/core";
|
||||
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
|
||||
import { Intent } from "./intent.js";
|
||||
|
||||
/**
|
||||
* Prompt builder for the Intent Decoder.
|
||||
* Separates prompt generation, structure, and component breakdowns.
|
||||
*/
|
||||
export class IntentDecoderPromptBuilder implements IPromptBuilder<
|
||||
[WorldState, string, string, Intent[]]
|
||||
> {
|
||||
build(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
processedProse: string,
|
||||
recentIntents: Intent[],
|
||||
): PromptBreakdown {
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
// 1. Get other entities co-located in the same context
|
||||
const otherEntitiesLines: string[] = [];
|
||||
for (const otherEntity of worldState.entities.values()) {
|
||||
if (
|
||||
otherEntity.id !== actorId &&
|
||||
otherEntity.locationId === actor?.locationId
|
||||
) {
|
||||
const alias = actor
|
||||
? resolveAlias(actor, otherEntity.id)
|
||||
: otherEntity.id;
|
||||
otherEntitiesLines.push(` - Alias="${alias}" ID=${otherEntity.id}`);
|
||||
}
|
||||
}
|
||||
const otherEntitiesContext =
|
||||
otherEntitiesLines.length > 0
|
||||
? otherEntitiesLines.join("\n")
|
||||
: " (No other entities in context)";
|
||||
|
||||
// 2. Format historical context (2-3 recent intents received by the actor)
|
||||
const historicalLines: string[] = [];
|
||||
for (const prior of recentIntents) {
|
||||
const targetIds =
|
||||
prior.actorId !== actorId ? [prior.actorId] : prior.targetIds;
|
||||
const targetsStr = targetIds
|
||||
.map((tid) => {
|
||||
const alias = actor ? resolveAlias(actor, tid) : tid;
|
||||
return `(Alias="${alias}", ID="${tid}")`;
|
||||
})
|
||||
.join(", ");
|
||||
historicalLines.push(
|
||||
` - Content: "${prior.content}", Type: ${prior.type}, Target Entities: ${targetsStr || "None"}`,
|
||||
);
|
||||
}
|
||||
const historicalContext =
|
||||
historicalLines.length > 0
|
||||
? historicalLines.join("\n")
|
||||
: " (No prior intents in context)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical action performed in the world (e.g., moving, opening, looking). DO NOT CLASSIFY SPEAKING MODIFIERS AS ACTIONS
|
||||
- "monologue" (or "thought"): An inner thought, reflection, or monologue/self narration.
|
||||
2. Extract the original narrative text fragment from the prose that corresponds to this intent and populate it as "content". Do not paraphrase, do not convert to third person, and do not convert to second person. Keep the original text fragment exactly as written in the prose (first-person voice).
|
||||
3. Identify targetIds — the entity IDs of the receiving parties. Use the "Other entities in context" list to resolve any subjective names, aliases, or descriptions used in the prose to their correct entity IDs. If no specific target, use an empty array.
|
||||
4. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
|
||||
5. For dialogue intents always use the following format for content field:
|
||||
I say "<dialogue>" (optionally: to him/her/alias).
|
||||
`.trim();
|
||||
|
||||
const decoderContext = `
|
||||
Intent Source: ${actorId}
|
||||
Other entities in context:
|
||||
${otherEntitiesContext}
|
||||
Historical Context:
|
||||
${historicalContext}
|
||||
`.trim();
|
||||
|
||||
const narrativeProseSection = `=== NARRATIVE PROSE ===\n${processedProse}`;
|
||||
|
||||
const userContext = `${decoderContext}\n\n${narrativeProseSection}`;
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "Decoder Context", type: "world", content: decoderContext },
|
||||
{
|
||||
label: "Narrative Prose",
|
||||
type: "input",
|
||||
content: narrativeProseSection,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,15 @@ import { z } from "zod";
|
||||
* - "action": A physical or logical action performed in the world.
|
||||
* - "monologue": An inner thought or internal monologue. Not perceivable by
|
||||
* any other entity. Bypasses the Architect/validators entirely and is
|
||||
* written directly to the actor's memory buffer with no outcome.
|
||||
* written directly to the actor's Cognitive Buffer with no outcome.
|
||||
* - "thought": Equivalent/alias to "monologue".
|
||||
*/
|
||||
export const IntentTypeSchema = z.enum(["dialogue", "action", "monologue"]);
|
||||
export const IntentTypeSchema = z.enum([
|
||||
"dialogue",
|
||||
"action",
|
||||
"monologue",
|
||||
"thought",
|
||||
]);
|
||||
export type IntentType = z.infer<typeof IntentTypeSchema>;
|
||||
|
||||
/**
|
||||
@@ -18,19 +24,13 @@ export const LLMIntentSchema = z.object({
|
||||
/** The type of intent. */
|
||||
type: IntentTypeSchema,
|
||||
|
||||
/** The original narrative text fragment this intent was extracted from. */
|
||||
originalText: z.string(),
|
||||
|
||||
/** A concise, structured description of the intent's action or dialogue. */
|
||||
description: z.string(),
|
||||
|
||||
/** The same event from the actor's own perspective (second person, "You"). */
|
||||
selfDescription: z.string(),
|
||||
/** The dehydrated canonical content of the intent. */
|
||||
content: z.string(),
|
||||
|
||||
/**
|
||||
* Entity IDs of the receiving parties (e.g., who is being spoken to,
|
||||
* what object is being interacted with). Always an empty array for
|
||||
* "monologue" intents, since they are not perceivable by anyone.
|
||||
* "monologue" and "thought" intents, since they are not perceivable by anyone.
|
||||
*/
|
||||
targetIds: z.array(z.string()),
|
||||
|
||||
@@ -56,8 +56,14 @@ export const LLMIntentSequenceSchema = z.object({
|
||||
* The full output of the Intent Decoder: an ordered sequence of intents
|
||||
* extracted from a single narrative prose block.
|
||||
*/
|
||||
import { PromptComponent } from "@omnia/llm";
|
||||
|
||||
export const IntentSequenceSchema = z.object({
|
||||
intents: z.array(IntentSchema),
|
||||
});
|
||||
|
||||
export type IntentSequence = z.infer<typeof IntentSequenceSchema>;
|
||||
export type IntentSequence = z.infer<typeof IntentSequenceSchema> & {
|
||||
systemPrompt?: string;
|
||||
userContext?: string;
|
||||
promptComponents?: PromptComponent[];
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { WorldState, Entity } from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { IntentDecoder, IntentSequence } from "@omnia/intent";
|
||||
import { IntentDecoder } from "@omnia/intent";
|
||||
|
||||
describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
test("decodes prose with a single action intent", async () => {
|
||||
@@ -9,13 +9,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "action",
|
||||
originalText: "Alice opened the chest.",
|
||||
description: "Open the wooden chest.",
|
||||
selfDescription: "You open the wooden chest.",
|
||||
content: "I open the wooden chest.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -34,6 +32,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
expect(result.intents).toHaveLength(1);
|
||||
expect(result.intents[0].type).toBe("action");
|
||||
expect(result.intents[0].actorId).toBe("alice");
|
||||
expect(result.intents[0].content).toContain("entity@alice[I]");
|
||||
expect(result.intents[0].targetIds).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -44,13 +43,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Do you have the key?" Alice asked Bob.',
|
||||
description: "Alice asks Bob if he has the key.",
|
||||
selfDescription: "You ask Bob if he has the key.",
|
||||
content: '"Do you have the key?" I asked Bob.',
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -68,6 +65,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
|
||||
expect(result.intents).toHaveLength(1);
|
||||
expect(result.intents[0].type).toBe("dialogue");
|
||||
expect(result.intents[0].content).toContain("entity@alice[I]");
|
||||
expect(result.intents[0].targetIds).toEqual(["bob"]);
|
||||
});
|
||||
|
||||
@@ -78,21 +76,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob requesting cover.",
|
||||
selfDescription: "You whisper to Bob requesting cover.",
|
||||
content: '"Cover me," I whispered to Bob.',
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Creep towards the door and pull the handle.",
|
||||
selfDescription: "You creep towards the door and pull the handle.",
|
||||
content: "I crept towards the door and pulled the handle.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -113,6 +107,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
expect(result.intents[0].targetIds).toEqual(["bob"]);
|
||||
expect(result.intents[1].type).toBe("action");
|
||||
expect(result.intents[1].actorId).toBe("alice");
|
||||
expect(result.intents[1].content).toContain("entity@alice[I]");
|
||||
});
|
||||
|
||||
test("throws on LLM failure", async () => {
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../core" }, { "path": "../llm" }]
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
> ## Documentation Index
|
||||
>
|
||||
> Fetch the complete documentation index at: https://docs.langchain.com/llms.txt
|
||||
> Use this file to discover all available pages before exploring further.
|
||||
|
||||
# OpenAI integrations
|
||||
|
||||
> Integrate with OpenAI using LangChain JavaScript.
|
||||
|
||||
LangChain integrates with OpenAI and Azure OpenAI through the `@langchain/openai` package.
|
||||
|
||||
> [OpenAI](https://en.wikipedia.org/wiki/OpenAI) is American artificial intelligence (AI) research laboratory
|
||||
> consisting of the non-profit `OpenAI Incorporated`
|
||||
> and its for-profit subsidiary corporation `OpenAI Limited Partnership`.
|
||||
> OpenAI conducts AI research with the declared intention of promoting and developing a friendly AI.
|
||||
> OpenAI systems run on an `Azure`-based supercomputing platform from `Microsoft`.
|
||||
|
||||
> The [OpenAI API](https://platform.openai.com/docs/models) is powered by a diverse set of models with different capabilities and price points.
|
||||
>
|
||||
> [ChatGPT](https://chat.openai.com) is the Artificial Intelligence (AI) chatbot developed by `OpenAI`.
|
||||
|
||||
## Installation and setup
|
||||
|
||||
- Get an OpenAI api key and set it as an environment variable (`OPENAI_API_KEY`)
|
||||
|
||||
## Chat model
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/chat/openai).
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## LLM
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/llms/openai).
|
||||
|
||||
<Tip>
|
||||
See [this section for general instructions on installing LangChain packages](/oss/javascript/langchain/install).
|
||||
</Tip>
|
||||
|
||||
```bash npm theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
npm install @langchain/openai @langchain/core
|
||||
```
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAI } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## Text embedding model
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/embeddings/openai)
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAIEmbeddings } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## Chain
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAIModerationChain } from "@langchain/classic/chains";
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
Middleware specifically designed for OpenAI models. Learn more about [middleware](/oss/javascript/langchain/middleware/overview).
|
||||
|
||||
| Middleware | Description |
|
||||
| ----------------------------------------- | --------------------------------------------------------- |
|
||||
| [Content moderation](#content-moderation) | Moderate agent traffic using OpenAI's moderation endpoint |
|
||||
|
||||
### Content moderation
|
||||
|
||||
Moderate agent traffic (user input, model output, and tool results) using OpenAI's moderation endpoint to detect and handle unsafe content. Content moderation is useful for the following:
|
||||
|
||||
- Applications requiring content safety and compliance
|
||||
- Filtering harmful, hateful, or inappropriate content
|
||||
- Customer-facing agents that need safety guardrails
|
||||
- Meeting platform moderation requirements
|
||||
|
||||
<Info>
|
||||
Learn more about [OpenAI's moderation models](https://platform.openai.com/docs/guides/moderation) and categories.
|
||||
</Info>
|
||||
|
||||
**API reference:** [`openAIModerationMiddleware`](https://reference.langchain.com/javascript/langchain/index/openAIModerationMiddleware)
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { createAgent, openAIModerationMiddleware } from "langchain";
|
||||
|
||||
const agent = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, databaseTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
exitBehavior: "end",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Accordion title="Configuration options">
|
||||
<ParamField body="model" type="string | BaseChatModel" required>
|
||||
OpenAI model to use for moderation. Can be either a model name string (e.g., `"openai:gpt-5.5"`) or a `BaseChatModel` instance. The middleware will use this model's client to access the moderation endpoint.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="moderationModel" type="ModerationModel" default="omni-moderation-latest">
|
||||
OpenAI moderation model to use. Options: `'omni-moderation-latest'`, `'omni-moderation-2024-09-26'`, `'text-moderation-latest'`, `'text-moderation-stable'`
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkInput" type="boolean" default="true">
|
||||
Whether to check user input messages before the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkOutput" type="boolean" default="true">
|
||||
Whether to check model output messages after the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkToolResults" type="boolean" default="false">
|
||||
Whether to check tool result messages before the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="exitBehavior" type="'error' | 'end' | 'replace'" default="'end'">
|
||||
How to handle violations when content is flagged. Options:
|
||||
|
||||
* `'end'` - End agent execution immediately with a violation message
|
||||
* `'error'` - Throw `OpenAIModerationError` exception
|
||||
* `'replace'` - Replace the flagged content with the violation message and continue
|
||||
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="violationMessage" type="string | undefined">
|
||||
Custom template for violation messages. Supports template variables:
|
||||
|
||||
* `{categories}` - Comma-separated list of flagged categories
|
||||
* `{category_scores}` - JSON string of category scores
|
||||
* `{original_content}` - The original flagged content
|
||||
|
||||
Default: `"I'm sorry, but I can't comply with that request. It was flagged for {categories}."`
|
||||
|
||||
</ParamField>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Full example">
|
||||
The middleware integrates OpenAI's moderation endpoint to check content at different stages:
|
||||
|
||||
**Moderation stages:**
|
||||
|
||||
- `checkInput` - User messages before model call
|
||||
- `checkOutput` - AI messages after model call
|
||||
- `checkToolResults` - Tool outputs before model call
|
||||
|
||||
**Exit behaviors:**
|
||||
|
||||
- `'end'` (default) - Stop execution with violation message
|
||||
- `'error'` - Throw exception for application handling
|
||||
- `'replace'` - Replace flagged content and continue
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { createAgent, openAIModerationMiddleware } from "langchain";
|
||||
|
||||
// Basic moderation
|
||||
const agent = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, customerDataTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Strict moderation with custom message
|
||||
const agentStrict = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, customerDataTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
checkToolResults: true,
|
||||
exitBehavior: "error",
|
||||
violationMessage:
|
||||
"Content policy violation detected: {categories}. " +
|
||||
"Please rephrase your request.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Moderation with replacement behavior
|
||||
const agentReplace = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
checkInput: true,
|
||||
exitBehavior: "replace",
|
||||
violationMessage: "[Content removed due to safety policies]",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
---
|
||||
|
||||
<div className="source-links">
|
||||
<Callout icon="terminal-2">
|
||||
[Connect these docs](/use-these-docs) to Claude, VSCode, and more via MCP for real-time answers.
|
||||
</Callout>
|
||||
|
||||
<Callout icon="edit">
|
||||
[Edit this page on GitHub](https://github.com/langchain-ai/docs/edit/main/src/oss/javascript/integrations/providers/openai.mdx) or [file an issue](https://github.com/langchain-ai/docs/issues/new/choose).
|
||||
</Callout>
|
||||
</div>
|
||||
@@ -105,6 +105,7 @@ export abstract class BaseLLMProvider implements ILLMProvider {
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
response: parsed,
|
||||
});
|
||||
|
||||
return { success: true, data: parsed, usage };
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import { ProviderRegistry } from "./registry.js";
|
||||
|
||||
export interface PromptComponent {
|
||||
label: string;
|
||||
type: "system" | "world" | "events" | "memories" | "input" | "other";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PromptBreakdown {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
components?: PromptComponent[];
|
||||
}
|
||||
|
||||
export interface IPromptBuilder<TArgs extends unknown[]> {
|
||||
build(...args: TArgs): PromptBreakdown;
|
||||
}
|
||||
|
||||
export interface LLMRequest<T extends z.ZodTypeAny> {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
@@ -33,6 +49,7 @@ export interface LLMCallRecord {
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export interface ILLMProvider {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@ export class MockLLMProvider implements ILLMProvider {
|
||||
registerGenerative("mock", () => new MockLLMProvider([]));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
static create(inst: ModelProviderInstance): ILLMProvider {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
@@ -48,15 +49,21 @@ export class MockLLMProvider implements ILLMProvider {
|
||||
return { success: false, error: "Mock responses exhausted" };
|
||||
}
|
||||
const usage = { inputTokens: 100, outputTokens: 50, totalTokens: 150 };
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
try {
|
||||
const parsed = request.schema.parse(next);
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
response: parsed,
|
||||
});
|
||||
return { success: true, data: parsed, usage };
|
||||
} catch (e) {
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
|
||||
@@ -66,6 +66,7 @@ vi.mock("@langchain/openai", () => {
|
||||
constructor(config: unknown) {
|
||||
this.config = config;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
embedQuery = vi.fn().mockImplementation(async (text: string) => {
|
||||
return [0.1, 0.2, 0.3];
|
||||
});
|
||||
|
||||
@@ -127,6 +127,10 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
expect(provider.lastCalls[0]).toEqual({
|
||||
systemPrompt: "system prompt",
|
||||
userContext: "user context",
|
||||
response: {
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
},
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity, resolveAlias } from "@omnia/core";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { hydrate } from "@omnia/voice";
|
||||
|
||||
export interface BufferEntry {
|
||||
id: string;
|
||||
ownerId: string; // Whose subjective memory buffer this lives in
|
||||
ownerId: string; // Whose Cognitive Buffer this entry lives in
|
||||
timestamp: string; // WorldClock.get().toISOString() at write time
|
||||
locationId: string | null; // Actor's location when this happened
|
||||
|
||||
@@ -23,32 +24,14 @@ export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity,
|
||||
): string {
|
||||
const isSelf = viewer.id === entry.intent.actorId;
|
||||
|
||||
if (isSelf) {
|
||||
let details = (
|
||||
entry.intent.selfDescription ||
|
||||
entry.intent.description ||
|
||||
entry.intent.originalText
|
||||
).trim();
|
||||
if (details.length > 0) {
|
||||
details = details.charAt(0).toUpperCase() + details.slice(1);
|
||||
}
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
return details;
|
||||
let details = hydrate(entry.intent.content, viewer).trim();
|
||||
if (details.length > 0) {
|
||||
details = details.charAt(0).toUpperCase() + details.slice(1);
|
||||
}
|
||||
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
const subjectStr = actorAlias.charAt(0).toUpperCase() + actorAlias.slice(1);
|
||||
|
||||
let details = (entry.intent.description || entry.intent.originalText).trim();
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
|
||||
return `${subjectStr} ${details}`;
|
||||
return details;
|
||||
}
|
||||
|
||||
export class BufferRepository {
|
||||
|
||||
61
packages/memory/src/handoff-prompt-builder.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Entity } from "@omnia/core";
|
||||
import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm";
|
||||
import { BufferEntry, serializeSubjectiveBufferEntry } from "./buffer.js";
|
||||
|
||||
/**
|
||||
* Prompt builder for the Handoff Engine.
|
||||
* Separates prompt generation, structure, and component breakdowns.
|
||||
*/
|
||||
export class HandoffPromptBuilder implements IPromptBuilder<
|
||||
[Entity, BufferEntry[], Date]
|
||||
> {
|
||||
build(entity: Entity, candidates: BufferEntry[], now: Date): PromptBreakdown {
|
||||
const candidatesList = candidates
|
||||
.map((entry) => {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
|
||||
})
|
||||
.join("\n---\n");
|
||||
|
||||
const systemPrompt = `
|
||||
You are the memory Handoff Engine. Your task is to process a list of Cognitive Buffer entries for an entity and select which memories to promote to the Memory Ledger, and which to forget or summarize.
|
||||
|
||||
Instructions:
|
||||
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. physical action and its outcome or trivial actions). Combine them into a single chunk.
|
||||
2. **Write in the third-person** for the events of other entities. (eg. Alan did that. Sarah did this, etc)
|
||||
2. **Write in first-person for the events that you yourself did. (eg. I did this, I did that.)
|
||||
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
|
||||
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
|
||||
4. Discard small body movements like looking around, sighing, etc that do not contextually hold any meaning after it is done.
|
||||
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
|
||||
6. **Retain in Cognitive Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the Cognitive Buffer for immediate context. Otherwise, set it to false so it is safely pruned from the Cognitive Buffer.
|
||||
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
|
||||
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
|
||||
`.trim();
|
||||
|
||||
const entityContext = `
|
||||
Subject Entity ID: ${entity.id}
|
||||
Current Time: ${now.toISOString()}
|
||||
`.trim();
|
||||
|
||||
const candidatesSection = `Cognitive Buffer Candidates for Handoff:\n${candidatesList}`;
|
||||
|
||||
const userContext = `${entityContext}\n\n${candidatesSection}`;
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "Entity Context", type: "world", content: entityContext },
|
||||
{
|
||||
label: "Cognitive Candidates",
|
||||
type: "input",
|
||||
content: candidatesSection,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
BufferRepository,
|
||||
} from "./buffer.js";
|
||||
import { LedgerEntry, LedgerRepository } from "./ledger.js";
|
||||
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
import { ILLMProvider, IEmbeddingProvider, PromptComponent } from "@omnia/llm";
|
||||
import { HandoffPromptBuilder } from "./handoff-prompt-builder.js";
|
||||
|
||||
export const HandoffChunkSchema = z.object({
|
||||
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
|
||||
@@ -33,7 +34,7 @@ export function getMemorySectionLength(
|
||||
now: Date,
|
||||
): number {
|
||||
if (entries.length === 0) {
|
||||
return `=== RECENT EVENTS ===\n(No recent events recorded.)`.length;
|
||||
return `=== COGNITIVE BUFFER ===\n(No entries recorded.)`.length;
|
||||
}
|
||||
|
||||
const groupedLines: string[] = [];
|
||||
@@ -52,7 +53,7 @@ export function getMemorySectionLength(
|
||||
groupedLines.push(` - ${serialized}`);
|
||||
}
|
||||
|
||||
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`.length;
|
||||
return `=== COGNITIVE BUFFER ===\n${groupedLines.join("\n")}`.length;
|
||||
}
|
||||
|
||||
function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
|
||||
@@ -85,7 +86,9 @@ function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
|
||||
|
||||
// Check the last N entries
|
||||
const lastN = bufferEntries.slice(-N);
|
||||
return lastN.every((e) => e.intent.type === "monologue");
|
||||
return lastN.every(
|
||||
(e) => e.intent.type === "monologue" || e.intent.type === "thought",
|
||||
);
|
||||
}
|
||||
|
||||
function checkAttributeTrigger(entity: Entity): boolean {
|
||||
@@ -200,53 +203,44 @@ export function splitBufferForHandoff(
|
||||
/**
|
||||
* HandoffEngine processes memory handoffs using LLM summarization and DB transactions.
|
||||
*/
|
||||
export interface HandoffRunResult {
|
||||
success: boolean;
|
||||
systemPrompt?: string;
|
||||
userContext?: string;
|
||||
promptComponents?: PromptComponent[];
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export class HandoffEngine {
|
||||
public lastResult: HandoffRunResult | null = null;
|
||||
private promptBuilder: HandoffPromptBuilder;
|
||||
|
||||
constructor(
|
||||
private llmProvider: ILLMProvider,
|
||||
private embedProvider: IEmbeddingProvider,
|
||||
private bufferRepo: BufferRepository,
|
||||
private ledgerRepo: LedgerRepository,
|
||||
) {}
|
||||
) {
|
||||
this.promptBuilder = new HandoffPromptBuilder();
|
||||
}
|
||||
|
||||
async runHandoff(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
this.lastResult = null;
|
||||
const { candidates } = splitBufferForHandoff(bufferEntries, now);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatesList = candidates
|
||||
.map((entry) => {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
|
||||
})
|
||||
.join("\n---\n");
|
||||
|
||||
const systemPrompt = `
|
||||
You are the memory Handoff Engine. Your task is to process a list of recent working memory buffer entries for an entity and select which memories to promote to the long-term Ledger, and which to forget or summarize.
|
||||
|
||||
Instructions:
|
||||
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. a full back-and-forth conversation or a single physical action and its outcome). Combine them into a single summary chunk.
|
||||
2. **Write in the third-person** for the "content" of each chunk (e.g. "John asked Mary for the key, and Mary reluctantly handed it over").
|
||||
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
|
||||
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
|
||||
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
|
||||
6. **Retain in Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the working memory buffer for immediate context. Otherwise, set it to false so it is safely pruned from the buffer.
|
||||
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
|
||||
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
Subject Entity ID: ${entity.id}
|
||||
Current Time: ${now.toISOString()}
|
||||
|
||||
Working Memory Candidates for Handoff:
|
||||
${candidatesList}
|
||||
`.trim();
|
||||
const { systemPrompt, userContext, components } = this.promptBuilder.build(
|
||||
entity,
|
||||
candidates,
|
||||
now,
|
||||
);
|
||||
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
@@ -255,11 +249,29 @@ ${candidatesList}
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
this.lastResult = {
|
||||
success: false,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
promptComponents: components,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
this.lastResult = {
|
||||
success: true,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
promptComponents: components,
|
||||
response: response.data,
|
||||
};
|
||||
|
||||
const result = response.data;
|
||||
const db = (this.bufferRepo as any).db;
|
||||
const db = (
|
||||
this.bufferRepo as unknown as {
|
||||
db: { transaction: (fn: () => void) => () => void };
|
||||
}
|
||||
).db;
|
||||
|
||||
const ledgerEntries: LedgerEntry[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
checkHandoffTrigger,
|
||||
splitBufferForHandoff,
|
||||
HandoffEngine,
|
||||
splitBufferForHandoff,
|
||||
checkHandoffTrigger,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
const now = new Date("2026-07-07T12:00:00.000Z");
|
||||
const now = new Date("2026-07-09T08:00:00.000Z");
|
||||
|
||||
test("splitBufferForHandoff correctly splits based on watermark and fresh buckets", () => {
|
||||
describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
test("splitBufferForHandoff identifies candidate entries based on recency", () => {
|
||||
const entries: BufferEntry[] = [];
|
||||
|
||||
// Add 12 older entries (older than 30 minutes)
|
||||
@@ -30,8 +30,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Old event ${i}`,
|
||||
description: `does old thing ${i}`,
|
||||
content: `entity@alice[I] do old thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
@@ -54,8 +53,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Fresh event ${idx}`,
|
||||
description: `does fresh thing ${idx}`,
|
||||
content: `entity@alice[I] do fresh thing ${idx}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
@@ -84,8 +82,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: "hello",
|
||||
description: "says hello",
|
||||
content: "entity@alice[I] say hello",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
@@ -100,8 +97,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-2",
|
||||
intent: {
|
||||
type: "monologue",
|
||||
originalText: "think",
|
||||
description: "thinks",
|
||||
content: "entity@alice[I] think",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
@@ -138,8 +134,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: i % 2 === 0 ? "dialogue" : "action",
|
||||
originalText: `Event ${i}`,
|
||||
description: `does thing ${i}`,
|
||||
content: `entity@alice[I] do thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { Entity, SQLiteRepository } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
serializeSubjectiveBufferEntry,
|
||||
resolveAlias,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
test("resolveAlias correctly handles self and fallbacks", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
|
||||
expect(resolveAlias(viewer, "alice")).toBe("you");
|
||||
expect(resolveAlias(viewer, "bob")).toBe("the hooded figure");
|
||||
expect(resolveAlias(viewer, "charlie")).toBe("an unfamiliar figure");
|
||||
});
|
||||
|
||||
test("serializes dialogue intent substituting target/actor aliases", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
@@ -31,9 +21,8 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: '"Hello there," Bob said to Charlie.',
|
||||
description: "says, 'Hello there' to the bartender",
|
||||
selfDescription: "You say, 'Hello there' to the bartender.",
|
||||
content:
|
||||
"entity@bob[I] say 'Hello there' to entity@charlie[the bartender]",
|
||||
actorId: "bob",
|
||||
targetIds: ["charlie"],
|
||||
modifiers: [],
|
||||
@@ -42,7 +31,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe(
|
||||
"The hooded figure says, 'Hello there' to the bartender",
|
||||
"The hooded figure says 'Hello there' to the bartender",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,9 +46,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Bob tried to break the latch.",
|
||||
description: "attempts to break the lock latch",
|
||||
selfDescription: "You attempt to break the lock latch.",
|
||||
content: "entity@bob[I] attempt to break the lock latch",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -86,9 +73,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "I opened the window.",
|
||||
description: "open the window",
|
||||
selfDescription: "You open the window.",
|
||||
content: "entity@alice[I] open the window",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -96,7 +81,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("You open the window.");
|
||||
expect(resultSelf).toBe("I open the window");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
@@ -105,9 +90,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Someone knocked.",
|
||||
description: "knocks on the door",
|
||||
selfDescription: "You knock on the door.",
|
||||
content: "entity@stranger-1[I] knock on the door",
|
||||
actorId: "stranger-1",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -136,9 +119,7 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "Alice picked up a stick.",
|
||||
description: "Alice gathers a stick",
|
||||
selfDescription: "You gather a stick.",
|
||||
content: "entity@alice[I] gather a stick",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -196,8 +177,7 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
locationId: "forest",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Alice sneezed.",
|
||||
description: "Alice sneezes",
|
||||
content: "entity@alice[I] sneeze",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" }
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -145,8 +145,14 @@ export class ScenarioLoader {
|
||||
timestamp: mem.timestamp,
|
||||
locationId: mem.locationId,
|
||||
intent: {
|
||||
...mem.intent,
|
||||
selfDescription: mem.intent.selfDescription ?? "",
|
||||
type: mem.intent.type,
|
||||
content:
|
||||
mem.intent.content ||
|
||||
mem.intent.description ||
|
||||
mem.intent.originalText ||
|
||||
"",
|
||||
actorId: mem.intent.actorId,
|
||||
targetIds: mem.intent.targetIds,
|
||||
modifiers: mem.intent.modifiers ?? [],
|
||||
},
|
||||
outcome: mem.outcome,
|
||||
|
||||