16 Commits

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

* refactor(ci):  Split dockerfile into layers
2026-07-19 18:04:27 +05:30
rhit-lid2
1e34becec7 refactor(gui): Unify prompt analysis components for actor, intent decoder and Handoff 2026-07-19 16:18:15 +05:30
rhit-lid2
f8977a14c6 refactor(gui): Use structured logging over string parsing 2026-07-19 16:08:55 +05:30
c2926261a1 refactor(content): Updated talking room to add more coherent memory 2026-07-19 16:03:20 +05:30
5c3a79e8b6 fix(voice): Quote splitting based on single and double quotes 2026-07-19 13:35:19 +05:30
a4b620502a FEAT!(voice): Implement intent hydration, dehydration system fixes: #29 2026-07-19 13:13:07 +05:30
84bff92631 refactor(intent): Improve intent decoder userContext structure 2026-07-19 11:01:06 +05:30
74 changed files with 2471 additions and 1162 deletions

21
.dockerignore Normal file
View File

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

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

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

59
apps/gui/Dockerfile Normal file
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

View File

@@ -271,6 +271,12 @@ export async function createProviderInstance(
);
}
export async function duplicateProviderInstance(
id: string,
): Promise<ModelProviderInstance | null> {
return ProviderManager.duplicate(id);
}
export async function deleteProviderInstance(id: string): Promise<void> {
ProviderManager.delete(id);
}

View File

@@ -7,6 +7,7 @@ import {
setActiveProviderInstance,
regenerateEmbeddings,
deleteProviderInstance,
duplicateProviderInstance,
fetchAvailableModels,
fetchAvailableModelsForInstance,
} from "@/app/actions";
@@ -43,13 +44,7 @@ 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";
@@ -200,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) => {
@@ -346,6 +339,23 @@ export function ProviderInstancesConfig({
}
};
const handleDuplicate = async () => {
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
try {
setLoading(true);
setError("");
const duplicated = await duplicateProviderInstance(selectedInstanceId);
if (duplicated) {
setSelectedInstanceId(duplicated.id);
await onChanged();
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return (
<section className="mb-8 flex min-h-[600px] flex-col">
{error && (
@@ -654,16 +664,26 @@ export function ProviderInstancesConfig({
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<div className="flex flex-row gap-2">
{selectedInstanceId !== "new" && (
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
Delete
</Button>
<>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
Delete
</Button>
<Button
type="button"
variant="secondary"
onClick={handleDuplicate}
disabled={loading}
>
Duplicate
</Button>
</>
)}
</div>
<Button type="submit" disabled={loading}>

View File

@@ -25,15 +25,8 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Bot, UserRound } from "lucide-react";
export function HomeView() {
const router = useRouter();
@@ -192,7 +185,7 @@ export function HomeView() {
return (
<div className="flex-1 overflow-y-auto w-full relative">
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
<div className="relative z-10 mx-auto max-w-5xl px-10 py-12">
<div className="animate-fade-in">
{/* Centered Big Logo */}
<div className="flex flex-col items-center justify-center mb-10 pt-4">
@@ -237,7 +230,7 @@ export function HomeView() {
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-[148px]"
className="shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-37"
>
<div className="space-y-3">
<Skeleton className="h-5 w-2/3" />
@@ -264,7 +257,7 @@ export function HomeView() {
? undefined
: () => handleResume(s.id)
}
className={`flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
className={`shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all relative group ${
providerInstances.length === 0
? "opacity-50 cursor-not-allowed filter grayscale"
: "cursor-pointer hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm"
@@ -310,8 +303,8 @@ export function HomeView() {
</h2>
{loadingData ? (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<Link href="/builder" className="no-underline shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
<div>
<strong className="text-body-md text-foreground block mb-1">
Build a scenario
@@ -331,7 +324,7 @@ export function HomeView() {
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-[148px]"
className="shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-37"
>
<div className="space-y-3">
<Skeleton className="h-5 w-3/4" />
@@ -345,8 +338,8 @@ export function HomeView() {
</div>
) : (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<Link href="/builder" className="no-underline shrink-0">
<div className="w-64 border border-primary bg-primary p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-37">
<div>
<strong className="text-body-md text-surface block mb-1">
Build a scenario
@@ -382,7 +375,7 @@ export function HomeView() {
open={!!scenarioForModal}
onOpenChange={(open) => !open && setScenarioForModal(null)}
>
<DialogContent className="max-w-[400px]">
<DialogContent className="max-w-100">
<DialogHeader className="border-b border-dotted border-border/20 pb-4 mb-2">
<DialogTitle>Start Scenario</DialogTitle>
<DialogDescription>
@@ -412,28 +405,37 @@ export function HomeView() {
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Simulation Mode / Play as
</label>
<Select
value={selectedEntityForModal}
onValueChange={(val) =>
setSelectedEntityForModal(val || "")
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="-- Run Fully Autonomously --" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="">
-- Run Fully Autonomously --
</SelectItem>
{modalEntities.map((ent) => (
<SelectItem key={ent.id} value={ent.id}>
Play as {ent.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={
selectedEntityForModal === ""
? "default"
: "outline"
}
onClick={() => setSelectedEntityForModal("")}
className="flex-1 min-w-40"
>
<Bot />
Run Fully Autonomously
</Button>
{modalEntities.map((ent) => (
<Button
key={ent.id}
type="button"
variant={
selectedEntityForModal === ent.id
? "default"
: "outline"
}
onClick={() => setSelectedEntityForModal(ent.id)}
className="flex-1 min-w-40"
>
<UserRound />
Play as {ent.name}
</Button>
))}
</div>
</div>
</div>
)}

View File

@@ -9,6 +9,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { PromptAnalyzer } from "@/components/play/PromptAnalyzer";
interface HandoffModalProps {
entry: SimSnapshot["log"][number];
@@ -32,7 +33,7 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[800px] sm:max-w-[800px] h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
<DialogContent className="max-w-200 sm:max-w-200 h-[85vh] overflow-hidden flex flex-col p-0 gap-0 border-2">
<DialogHeader className="px-6 pt-5 pb-4 border-b">
<DialogTitle className="text-lg font-head tracking-wide text-primary flex items-center justify-between">
<span>Memory Handoff Details &mdash; {entry.entityName}</span>
@@ -125,91 +126,119 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Memory Ledger Additions
</h3>
{chunks.map((chunk: any, index: number) => (
<div
key={index}
className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)] relative flex flex-col gap-3"
>
<div className="flex justify-between items-start gap-4">
<div className="flex-1 text-sm text-foreground/90 leading-relaxed font-sans">
{chunk.content}
</div>
<Badge
variant="outline"
className={`font-mono text-xs ${getImportanceColor(chunk.importance)}`}
>
Importance: {chunk.importance}
</Badge>
</div>
{chunk.quotes && chunk.quotes.length > 0 && (
<div className="bg-secondary/10 border-l-2 border-primary/50 p-2.5 my-1 text-xs italic text-muted-foreground space-y-1">
{chunk.quotes.map((quote: string, qIdx: number) => (
<div key={qIdx}>&ldquo;{quote}&rdquo;</div>
))}
</div>
)}
<div className="flex flex-wrap gap-2 text-xs pt-2 border-t border-dotted border-border/10">
{chunk.retainInBuffer ? (
{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="bg-primary/5 text-primary border-primary/20 text-[10px] font-mono"
className={`font-mono text-xs ${getImportanceColor(chunk.importance)}`}
>
Pinned in Buffer
</Badge>
) : (
<Badge
variant="outline"
className="bg-muted text-muted-foreground border-border/20 text-[10px] font-mono"
>
Pruned from Buffer
Importance: {chunk.importance}
</Badge>
</div>
{chunk.quotes && chunk.quotes.length > 0 && (
<div className="bg-secondary/10 border-l-2 border-primary/50 p-2.5 my-1 text-xs italic text-muted-foreground space-y-1">
{chunk.quotes.map((quote: string, qIdx: number) => (
<div key={qIdx}>&ldquo;{quote}&rdquo;</div>
))}
</div>
)}
{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 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>
)}
</div>
)}
{activeTab === "prompt" && entry.rawPrompt && (
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono mb-2">
System Prompt
</h4>
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-[250px] overflow-y-auto">
{entry.rawPrompt.systemPrompt}
</pre>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono mb-2">
User Context (Candidates)
</h4>
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border max-h-[350px] overflow-y-auto font-sans leading-relaxed font-mono">
{entry.rawPrompt.userContext}
</pre>
</div>
</div>
<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" && (
@@ -217,7 +246,7 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Raw JSON Output
</h4>
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border flex-1 overflow-y-auto max-h-[500px]">
<pre className="p-3 bg-muted rounded text-xs font-mono whitespace-pre-wrap text-foreground border flex-1 overflow-y-auto max-h-125">
{handoffResult
? JSON.stringify(handoffResult, null, 2)
: "No JSON Output recorded."}

View File

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

View File

@@ -1,44 +1,61 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import type { SimSnapshot } from "@/lib/simulation-types";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { hydrate } from "@omnia/voice";
import { Brain, PersonStanding, Speech } from "lucide-react";
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { InteractDock } from "./InteractDock";
function IntentTag({
intent,
isSelf,
playerAliases,
playerId,
entities,
}: {
intent: SimSnapshot["log"][number]["intents"][number];
isSelf?: boolean;
playerAliases: Record<string, string>;
playerId: string;
entities: SimSnapshot["entities"];
}) {
const labels: Record<string, string> = {
monologue: "thought",
thought: "thought",
dialogue: "dialogue",
action: "action",
const icons: Record<string, React.ReactNode> = {
monologue: <Brain className="size-4" />,
thought: <Brain className="size-4" />,
dialogue: <Speech className="size-4" />,
action: <PersonStanding className="size-4" />,
};
const label = labels[intent.type] || intent.type;
const icon = icons[intent.type] || null;
let outcome = "";
if (intent.type === "action") {
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
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 textToDisplay =
isSelf && intent.selfDescription
? intent.selfDescription
: intent.description;
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 ? (
@@ -47,12 +64,28 @@ function IntentTag({
</span>
) : null;
const invalidActionReason =
intent.type === "action" && !intent.isValid && intent.reason
? ` (${intent.reason})`
: "";
const invalidActionClassName =
intent.type === "action" && !intent.isValid ? " text-destructive" : "";
return (
<span className="text-sm text-muted-foreground">
[{label}] &ldquo;{textToDisplay}&rdquo;{modifiersStr}
{outcome}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
<>
<span className="text-sm text-muted-foreground inline-flex items-start gap-1">
<span className="mt-0.5 inline-flex shrink-0 items-center justify-center">
{icon}
</span>
<span className={invalidActionClassName}>
&ldquo;{textToDisplay}&rdquo;{modifiersStr}
{invalidActionReason}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
</span>
<br />
</>
);
}
@@ -76,48 +109,62 @@ 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(
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
<div className={cn("mb-2")}>
<div
className={cn(
"border p-4 shadow-sm",
isPlayerCard
? "border-primary bg-surface-container-low"
: "border-border/30 bg-card",
)}
>
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
<div className="flex items-center gap-2">
<strong className="text-body-md font-bold text-foreground">
{entry.entityName}
</strong>
<span className="text-xs text-muted-foreground font-mono">
Turn {entry.turn} &middot; {formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
<Button
variant="ghost"
size="icon"
onClick={() => onShowPrompt(entry)}
title="View Raw Prompts & Token Usage"
>
</Button>
)}
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
</div>
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
{entry.narrativeProse}
</div>
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
<div className={cn("mt-3 ms-3")}>
{entry.intents.map((intent, i) => (
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
<IntentTag
key={i}
intent={intent}
playerAliases={playerAliases}
playerId={playerId}
entities={entities}
/>
))}
</div>
</div>
@@ -134,6 +181,9 @@ interface InteractViewProps {
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
onShowHandoff: (entry: SimSnapshot["log"][number]) => void;
logEndRef: React.RefObject<HTMLDivElement | null>;
onPauseRequested: () => void;
onResumeRequested: () => void;
onStopRequested: () => void;
}
export function InteractView({
@@ -146,16 +196,17 @@ export function InteractView({
onShowPrompt,
onShowHandoff,
logEndRef,
onPauseRequested,
onResumeRequested,
onStopRequested,
}: InteractViewProps) {
const router = useRouter();
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return (
<>
{/* Scrollable Center Viewport */}
<main className="flex-1 overflow-y-auto px-8 py-6">
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
<div className="flex flex-col gap-4 max-w-200 mx-auto pb-44 md:pb-52">
{snapshot.log.map((entry, i) => {
if (entry.isHandoff) {
return (
@@ -184,12 +235,18 @@ export function InteractView({
</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}
/>
);
})}
@@ -203,54 +260,16 @@ export function InteractView({
</div>
</main>
{/* Sticky Chat / Interaction Input Footer */}
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
<div className="max-w-[800px] mx-auto">
{snapshot.status === "waiting_player" && snapshot.waitingEntity ? (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
<strong>Your context as {snapshot.waitingEntity.name}</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form onSubmit={onSubmitAction} className="flex flex-col gap-2">
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button type="submit" disabled={loading || !playerInput.trim()}>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
) : snapshot.status === "done" || snapshot.status === "error" ? (
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
<span className="text-sm font-mono text-muted-foreground">
{snapshot.status === "error"
? "Simulation finished with an error."
: "Simulation complete."}
</span>
<Button
onClick={() => {
router.push("/");
}}
size="sm"
>
{snapshot.status === "error"
? "Back to Dashboard"
: "New Simulation"}
</Button>
</div>
) : null}
</div>
</footer>
<InteractDock
snapshot={snapshot}
loading={loading}
playerInput={playerInput}
setPlayerInput={setPlayerInput}
onSubmitAction={onSubmitAction}
onPauseRequested={onPauseRequested}
onResumeRequested={onResumeRequested}
onStopRequested={onStopRequested}
/>
</>
);
}

View File

@@ -319,43 +319,6 @@ export function PlayView() {
</p>
</div>
</div>
{/* Simulation Global Controls */}
<div className="flex gap-2 shrink-0">
{snapshot.status !== "done" && snapshot.status !== "error" && (
<>
{snapshot.status === "running" &&
(loading ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => runSteps(snapshot.id)}
>
Resume
</Button>
))}
<Button
variant="destructive"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
>
Stop
</Button>
</>
)}
</div>
</div>
<div className="flex items-center justify-between text-xs font-mono mt-1 pt-1.5 border-t border-border/10">
<span className="text-muted-foreground">
@@ -390,6 +353,14 @@ export function PlayView() {
onShowPrompt={setSelectedEntryForModal}
onShowHandoff={setSelectedHandoffForModal}
logEndRef={logEndRef}
onPauseRequested={() => {
pauseRequestedRef.current = true;
}}
onResumeRequested={() => runSteps(snapshot.id)}
onStopRequested={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
/>
) : (
<ManageView snapshot={snapshot} onRename={setSnapshot} />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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 (

View File

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

View File

@@ -39,12 +39,14 @@ export async function runHandoffResolution(session: SimSession): Promise<void> {
worldState.clock.get(),
);
if (ran) {
const lastResult = handoffEngine.lastResult;
const lastCall =
session.handoffProvider.lastCalls?.[
(session.handoffProvider.lastCalls?.length || 0) - 1
];
const info = session.entities.find((e) => e.id === entity.id);
const entityName = info?.name || entity.id;
session.log.push({
turn: session.turn,
entityId: entity.id,
@@ -53,14 +55,15 @@ export async function runHandoffResolution(session: SimSession): Promise<void> {
intents: [],
timestamp: worldState.clock.get().toISOString(),
isHandoff: true,
rawPrompt: lastCall
rawPrompt: lastResult
? {
systemPrompt: lastCall.systemPrompt,
userContext: lastCall.userContext,
systemPrompt: lastResult.systemPrompt || "",
userContext: lastResult.userContext || "",
components: lastResult.promptComponents,
}
: undefined,
usage: lastCall?.usage,
handoffResult: lastCall?.response,
handoffResult: (lastResult?.response || lastCall?.response) as any,
});
}
}

View File

@@ -468,6 +468,38 @@ export class SimulationManager {
// ---------------------------------------------------------------------------
private snapshot(session: SimSession): SimSnapshot {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
const hydratedEntities = session.entities.map((e) => {
const actualEntity = worldState?.getEntity(e.id);
const aliases: Record<string, string> = {};
if (actualEntity) {
for (const [targetId, alias] of actualEntity.aliases.entries()) {
aliases[targetId] = alias;
}
}
return {
...e,
aliases,
};
});
// Get current location from the waiting entity
let currentLocation: string | undefined;
if (
worldState &&
session.entityIndex >= 0 &&
session.entityIndex < session.entities.length
) {
const currentEntityInfo = session.entities[session.entityIndex];
const actualEntity = worldState.getEntity(currentEntityInfo.id);
if (actualEntity?.locationId) {
const location = worldState.getLocation(actualEntity.locationId);
if (location) {
currentLocation = location.id;
}
}
}
return {
id: session.worldInstanceId,
status: session.status,
@@ -475,11 +507,13 @@ export class SimulationManager {
maxTurns: session.maxTurns,
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
entities: session.entities,
entities: hydratedEntities,
log: session.log,
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
worldTime: worldState?.clock.get().toISOString(),
currentLocation,
};
}
}

View File

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

View File

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

View File

@@ -77,20 +77,29 @@
"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": "zz3f29d2-as11-9811-9a99-b13c126d123e",
"timestamp": "2026-07-01T09:58:00.000Z",
"id": "10ak29d2-as11-9811-9a99-b13c126d123e",
"timestamp": "2026-07-09T06:00:00.000Z",
"locationId": "white-room",
"intent": {
"type": "action",
"originalText": "he wakes up from his sleep.",
"description": "",
"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": []
}
@@ -136,8 +145,7 @@
"locationId": "white-room",
"intent": {
"type": "action",
"originalText": "I wake up in an unfamiliar place.",
"description": "",
"content": "entity@bf3f29d2-cf11-4b11-9a99-b13c126d400e[I] wake up in an unfamiliar place.",
"actorId": "bf3f29d2-cf11-4b11-9a99-b13c126d400e",
"targetIds": []
}

15
docker-compose.yml Normal file
View File

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

View File

@@ -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"
}
}

View File

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

View File

@@ -4,7 +4,6 @@ import {
WorldState,
naturalizeTime,
serializeSubjectiveWorldState,
resolveAlias,
} from "@omnia/core";
import {
BufferEntry,
@@ -13,6 +12,8 @@ import {
LedgerEntry,
LedgerRepository,
} from "@omnia/memory";
import { hydrate } from "@omnia/voice";
import { PromptComponent, IPromptBuilder, PromptBreakdown } from "@omnia/llm";
/**
* Zod schema for the structured response expected from the actor LLM.
@@ -36,7 +37,9 @@ export type ActorResponse = z.infer<typeof ActorResponseSchema>;
* 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 Cognitive Buffer. Optional —
* if absent, the memory section is omitted.
@@ -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,16 +123,6 @@ Guidelines:
}
}
// --- Cognitive Buffer ---
const memorySection = this.buildCognitiveBufferSection(
entity,
recentEntries,
now,
);
if (memorySection) {
sections.push(memorySection);
}
// --- Recalled Memory Ledger ---
const ledgerSection = this.buildLedgerSection(
worldState,
@@ -128,11 +130,45 @@ Guidelines:
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 buildCognitiveBufferSection(
@@ -158,7 +194,7 @@ Guidelines:
entry.intent.actorId === entity.id &&
entry.intent.type === "dialogue"
) {
serialized = `You said: ${serialized}`;
serialized = `I said: ${serialized}`;
}
if (when !== currentGroup) {
@@ -250,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})`;
}

View File

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

View File

@@ -55,8 +55,8 @@ describe("ActorPromptBuilder with Memory Ledger 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: [],
},
});
@@ -67,7 +67,7 @@ describe("ActorPromptBuilder with Memory Ledger Integration", () => {
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: [],
@@ -78,12 +78,12 @@ describe("ActorPromptBuilder with Memory Ledger Integration", () => {
// Check Cognitive Buffer exists
expect(userContext).toContain("=== COGNITIVE BUFFER ===");
expect(userContext).toContain("You said: Alice greets Bob");
expect(userContext).toContain("I said: I say 'Hello there' to Strider");
// Check Memory Ledger exists
expect(userContext).toContain("=== MEMORY LEDGER ===");
// Bob should be resolved to Strider in the ledger content
expect(userContext).toContain("alice met Strider at the tavern.");
// 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."');
});

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export interface ProcessResult extends ValidationResult {
}
export class Architect {
private validator: LLMValidator;
public validator: LLMValidator;
private timeDeltaGenerator: TimeDeltaGenerator;
constructor(

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import { Intent } from "@omnia/intent";
import { hydrateObjective } from "@omnia/voice";
export const TimeDeltaSchema = z.object({
minutesToAdvance: z.number().int().nonnegative(),
@@ -46,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()}
@@ -55,8 +58,7 @@ ${serializeObjectiveWorldState(worldState)}
=== ACTION ===
Actor ID: ${intent.actorId}
Type: ${intent.type}
Description: "${intent.description}"
Original Text: "${intent.originalText}"
Content: "${objectiveContent}"
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
`.trim();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,14 +24,8 @@ 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,
@@ -62,8 +56,14 @@ export const LLMIntentSequenceSchema = z.object({
* The full output of the Intent Decoder: an ordered sequence of intents
* extracted from a single narrative prose block.
*/
import { PromptComponent } from "@omnia/llm";
export const IntentSequenceSchema = z.object({
intents: z.array(IntentSchema),
});
export type IntentSequence = z.infer<typeof IntentSequenceSchema>;
export type IntentSequence = z.infer<typeof IntentSequenceSchema> & {
systemPrompt?: string;
userContext?: string;
promptComponents?: PromptComponent[];
};

View File

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

View File

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

View File

@@ -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,7 +49,7 @@ export interface LLMCallRecord {
providerInstanceName?: string;
maxContext?: number;
};
response?: any;
response?: unknown;
}
export interface ILLMProvider {

View File

@@ -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

View File

@@ -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([]);
}

View File

@@ -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];
});

View File

@@ -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,

View File

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

View File

@@ -1,6 +1,7 @@
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;
@@ -23,32 +24,14 @@ export function serializeSubjectiveBufferEntry(
entry: BufferEntry,
viewer: Entity,
): string {
const isSelf = viewer.id === entry.intent.actorId;
if (isSelf) {
let details = (
entry.intent.selfDescription ||
entry.intent.description ||
entry.intent.originalText
).trim();
if (details.length > 0) {
details = details.charAt(0).toUpperCase() + details.slice(1);
}
if (entry.intent.type === "action" && entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
return details;
let details = hydrate(entry.intent.content, viewer).trim();
if (details.length > 0) {
details = details.charAt(0).toUpperCase() + details.slice(1);
}
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
const subjectStr = actorAlias.charAt(0).toUpperCase() + actorAlias.slice(1);
let details = (entry.intent.description || entry.intent.originalText).trim();
if (entry.intent.type === "action" && entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
return `${subjectStr} ${details}`;
return details;
}
export class BufferRepository {

View File

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

View File

@@ -6,7 +6,8 @@ import {
BufferRepository,
} from "./buffer.js";
import { LedgerEntry, LedgerRepository } from "./ledger.js";
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
import { ILLMProvider, IEmbeddingProvider, PromptComponent } from "@omnia/llm";
import { HandoffPromptBuilder } from "./handoff-prompt-builder.js";
export const HandoffChunkSchema = z.object({
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
@@ -202,55 +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 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 userContext = `
Subject Entity ID: ${entity.id}
Current Time: ${now.toISOString()}
Cognitive Buffer Candidates for Handoff:
${candidatesList}
`.trim();
const { systemPrompt, userContext, components } = this.promptBuilder.build(
entity,
candidates,
now,
);
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
@@ -259,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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,23 +128,24 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
// 7. Assert initial pre-seeded memories
const alphaMemories = bufferRepo.listForOwner(alphaId);
expect(alphaMemories).toHaveLength(2);
expect(alphaMemories).toHaveLength(3);
expect(alphaMemories[0].id).toBe("ab3f29d2-cf11-4111-9a99-b13c126d123e");
expect(alphaMemories[0].intent.type).toBe("monologue");
expect(alphaMemories[0].intent.originalText).toContain("jail");
expect(alphaMemories[0].intent.description).toBe("");
expect(alphaMemories[0].intent.content).toContain("jail");
expect(alphaMemories[1].id).toBe("zz3f29d2-as11-9811-9a99-b13c126d123e");
expect(alphaMemories[1].id).toBe("10ak29d2-as11-9811-9a99-b13c126d123e");
expect(alphaMemories[1].intent.type).toBe("action");
expect(alphaMemories[1].intent.originalText).toContain("sleep");
expect(alphaMemories[1].intent.description).toBe("");
expect(alphaMemories[1].intent.content).toContain("another man");
expect(alphaMemories[2].id).toBe("zz3f29d2-as11-9811-9a99-b13c126d123e");
expect(alphaMemories[2].intent.type).toBe("action");
expect(alphaMemories[2].intent.content).toContain("sleep");
const betaMemories = bufferRepo.listForOwner(betaId);
expect(betaMemories).toHaveLength(1);
expect(betaMemories[0].id).toBe("zx1f29d2-cf11-4111-9a99-b13c126d123e");
expect(betaMemories[0].intent.type).toBe("action");
expect(betaMemories[0].intent.originalText).toContain("unfamiliar");
expect(betaMemories[0].intent.description).toBe("");
expect(betaMemories[0].intent.content).toContain("unfamiliar");
db.close();
});

View File

@@ -0,0 +1,13 @@
{
"name": "@omnia/voice",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"compromise": "^14.14.0"
}
}

View File

@@ -0,0 +1,82 @@
import { splitQuotes } from "./dehydration.js";
/**
* Preprocessor that expands common contractions (e.g. "he's" -> "he is", "I'm" -> "I am")
* in non-quote segments of a text, keeping dialogue segments untouched.
*/
export function expandContractions(text: string): string {
if (!text) return "";
const contractionMap: Record<string, string> = {
"i'm": "I am",
"you're": "you are",
"he's": "he is",
"she's": "she is",
"it's": "it is",
"we're": "we are",
"they're": "they are",
"i've": "I have",
"you've": "you have",
"we've": "we have",
"they've": "they have",
"i'd": "I would",
"you'd": "you would",
"he'd": "he would",
"she'd": "she would",
"we'd": "we would",
"they'd": "they would",
"i'll": "I will",
"you'll": "you will",
"he'll": "he will",
"she'll": "she will",
"we'll": "we will",
"they'll": "they will",
"isn't": "is not",
"aren't": "are not",
"wasn't": "was not",
"weren't": "were not",
"haven't": "have not",
"hasn't": "has not",
"hadn't": "had not",
"won't": "will not",
"wouldn't": "would not",
"don't": "do not",
"doesn't": "does not",
"didn't": "did not",
"can't": "cannot",
"couldn't": "could not",
"shouldn't": "should not",
"mightn't": "might not",
"mustn't": "must not",
};
const segments = splitQuotes(text);
const processed = segments.map((seg) => {
if (seg.isQuote) {
return `"${seg.text}"`;
}
let chunk = seg.text;
Object.entries(contractionMap).forEach(([contraction, replacement]) => {
const escaped = contraction.replace("'", "'");
const regex = new RegExp(`\\b${escaped}\\b`, "gi");
chunk = chunk.replace(regex, (matched) => {
const isCapitalized = matched[0] === matched[0].toUpperCase();
let finalRep = replacement;
if (isCapitalized) {
finalRep = finalRep[0].toUpperCase() + finalRep.slice(1);
} else {
if (finalRep.startsWith("I ")) {
// Keep I capitalized
} else {
finalRep = finalRep[0].toLowerCase() + finalRep.slice(1);
}
}
return finalRep;
});
});
return chunk;
});
return processed.join("");
}

View File

@@ -0,0 +1,147 @@
export interface Segment {
text: string;
isQuote: boolean;
}
/**
* Splits text into quote and non-quote segments.
*/
export function splitQuotes(text: string): Segment[] {
const segments: Segment[] = [];
let current = "";
let inQuote = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') {
if (current) {
segments.push({ text: current, isQuote: inQuote });
current = "";
}
inQuote = !inQuote;
} else {
current += char;
}
}
if (current) {
segments.push({ text: current, isQuote: inQuote });
}
return segments;
}
/**
* Transforms standard narrative prose from the source actor's perspective
* into a dehydrated canonical form with entity@<id>[original] placeholder tags.
*/
export function dehydrate(
content: string,
sourceId: string,
targetIds: string[],
aliasMap: Record<string, string>,
): string {
if (!content) return "";
const segments = splitQuotes(content);
const processedSegments = segments.map((seg) => {
if (seg.isQuote) {
return `"${seg.text}"`;
}
let text = seg.text;
// 1. Map lowercase aliases/names/IDs to IDs
const nameToId = new Map<string, string>();
// Add target IDs and source ID themselves
nameToId.set(sourceId.toLowerCase(), sourceId);
targetIds.forEach((id) => {
nameToId.set(id.toLowerCase(), id);
});
// Add entries from aliasMap (mapped lowercased)
Object.entries(aliasMap).forEach(([name, id]) => {
nameToId.set(name.toLowerCase(), id);
});
// Sort names by length descending to match longest name first
const sortedNames = Array.from(nameToId.keys()).sort(
(a, b) => b.length - a.length,
);
// Track state of matched target IDs for pronoun lookback
const matchedTargetIds: string[] = [];
// 2. Replace names and aliases with entity@<id>[name]
sortedNames.forEach((name) => {
const id = nameToId.get(name)!;
const escapedName = name.replace(
new RegExp("[-/\\\\^$*+?.()|[\\]{}]", "g"),
"\\$&",
);
const regex = new RegExp(`\\b${escapedName}\\b`, "gi");
text = text.replace(regex, (matched) => {
if (id !== sourceId) {
matchedTargetIds.push(id);
}
return `entity@${id}[${matched}]`;
});
});
// 3. Replace first-person pronouns with source actor tag
const firstPersonPronouns = [
{ word: "i" },
{ word: "me" },
{ word: "my" },
{ word: "myself" },
{ word: "mine" },
{ word: "we" },
{ word: "us" },
{ word: "our" },
{ word: "ours" },
{ word: "ourselves" },
];
firstPersonPronouns.forEach(({ word }) => {
const regex = new RegExp(`\\b${word}\\b`, "gi");
text = text.replace(regex, (matched) => {
return `entity@${sourceId}[${matched}]`;
});
});
// 4. Replace third-person pronouns using state lookback
const thirdPersonPronouns = [
"he",
"him",
"his",
"himself",
"she",
"her",
"hers",
"herself",
"they",
"them",
"their",
"theirs",
"themselves",
];
thirdPersonPronouns.forEach((pronoun) => {
const regex = new RegExp(`\\b${pronoun}\\b`, "gi");
text = text.replace(regex, (matched) => {
const lastTargetId =
matchedTargetIds[matchedTargetIds.length - 1] || targetIds[0];
if (lastTargetId) {
return `entity@${lastTargetId}[${matched}]`;
}
return matched;
});
});
return text;
});
return processedSegments.join("");
}

View File

@@ -0,0 +1,238 @@
import nlp from "compromise";
import { Entity, WorldState, resolveAlias } from "@omnia/core";
import { splitQuotes } from "./dehydration.js";
/**
* Hydrates a dehydrated narration text containing entity@<id>[original] symbol tags
* into natural language from a specific viewer's perspective.
*/
export function hydrate(content: string, viewer: Entity): string {
if (!content) return "";
const segments = splitQuotes(content);
const processedSegments = segments.map((seg) => {
if (seg.isQuote) {
return `'${seg.text}'`;
}
// Match entity@<id>[original] and optionally the following space and word
const regex = /entity@([a-zA-Z0-9-]+)\[([^\]]+)\](?:\s+([a-zA-Z]+))?/g;
const firstPersonSet = new Set([
"i",
"me",
"my",
"myself",
"mine",
"we",
"us",
"our",
"ours",
]);
const thirdPersonSet = new Set([
"he",
"him",
"his",
"himself",
"she",
"her",
"hers",
"herself",
"they",
"them",
"their",
"theirs",
"themselves",
]);
return seg.text.replace(regex, (matchStr, id, original, followingWord) => {
const isSelf = id === viewer.id;
const lowerOriginal = original.toLowerCase();
let resolvedSubject: string;
let isThirdPersonSingular = false;
if (isSelf) {
if (["his", "her", "their", "my", "its", "our"].includes(lowerOriginal))
resolvedSubject = "my";
else if (["hers", "theirs", "mine", "ours"].includes(lowerOriginal))
resolvedSubject = "mine";
else if (
[
"himself",
"herself",
"themselves",
"myself",
"itself",
"ourselves",
].includes(lowerOriginal)
)
resolvedSubject = "myself";
else if (["he", "she", "they", "i", "we"].includes(lowerOriginal))
resolvedSubject = "I";
else if (["him", "her", "them", "me", "us"].includes(lowerOriginal))
resolvedSubject = "me";
else {
// Noun/alias mapped to self: check preceding/succeeding context
const matchIdx = seg.text.indexOf(matchStr);
const precedingText = seg.text.slice(0, matchIdx);
const prec = precedingText.trim();
const words = prec.split(/\s+/);
const lastWord = words[words.length - 1]?.toLowerCase() || "";
const prepositions = [
"to",
"with",
"for",
"at",
"by",
"from",
"in",
"on",
"about",
"between",
"of",
"under",
"over",
"behind",
"beside",
"through",
];
if (prepositions.includes(lastWord)) {
resolvedSubject = "me";
} else {
resolvedSubject = "I";
}
}
} else {
const alias = resolveAlias(viewer, id);
if (firstPersonSet.has(lowerOriginal)) {
if (["my", "our"].includes(lowerOriginal))
resolvedSubject = `${alias}'s`;
else if (["mine", "ours"].includes(lowerOriginal))
resolvedSubject = `${alias}'s`;
else if (["myself", "ourselves"].includes(lowerOriginal))
resolvedSubject = "himself";
else {
resolvedSubject = alias;
isThirdPersonSingular = true;
}
} else if (thirdPersonSet.has(lowerOriginal)) {
resolvedSubject = original;
if (["he", "she", "it"].includes(lowerOriginal)) {
isThirdPersonSingular = true;
}
} else {
resolvedSubject = alias;
isThirdPersonSingular = true;
}
}
if (followingWord) {
if (isThirdPersonSingular) {
const conj = nlp(followingWord).verbs().conjugate()[0] as
{ Infinitive?: string; PresentTense?: string } | undefined;
if (conj && conj.Infinitive === followingWord && conj.PresentTense) {
return `${resolvedSubject} ${conj.PresentTense}`;
}
}
return `${resolvedSubject} ${followingWord}`;
}
return resolvedSubject;
});
});
return processedSegments.join("");
}
/**
* Hydrates a dehydrated narration text containing entity@<id>[original] symbol tags
* into natural language from an objective world perspective.
*/
export function hydrateObjective(
content: string,
worldState: WorldState,
): string {
if (!content) return "";
const segments = splitQuotes(content);
const processedSegments = segments.map((seg) => {
if (seg.isQuote) {
return `'${seg.text}'`;
}
// Match entity@<id>[original] and optionally the following space and word
const regex = /entity@([a-zA-Z0-9-]+)\[([^\]]+)\](?:\s+([a-zA-Z]+))?/g;
const firstPersonSet = new Set([
"i",
"me",
"my",
"myself",
"mine",
"we",
"us",
"our",
"ours",
]);
const thirdPersonSet = new Set([
"he",
"him",
"his",
"himself",
"she",
"her",
"hers",
"herself",
"they",
"them",
"their",
"theirs",
"themselves",
]);
return seg.text.replace(regex, (matchStr, id, original, followingWord) => {
const entity = worldState.getEntity(id);
const name = entity?.attributes.get("name")?.getValue() || id;
const lowerOriginal = original.toLowerCase();
let resolvedSubject: string;
let isThirdPersonSingular = false;
if (firstPersonSet.has(lowerOriginal)) {
if (["my", "our"].includes(lowerOriginal))
resolvedSubject = `${name}'s`;
else if (["mine", "ours"].includes(lowerOriginal))
resolvedSubject = `${name}'s`;
else if (["myself", "ourselves"].includes(lowerOriginal))
resolvedSubject = "himself";
else {
resolvedSubject = name;
isThirdPersonSingular = true;
}
} else if (thirdPersonSet.has(lowerOriginal)) {
resolvedSubject = original;
if (["he", "she", "it"].includes(lowerOriginal)) {
isThirdPersonSingular = true;
}
} else {
resolvedSubject = name;
isThirdPersonSingular = true;
}
if (followingWord) {
if (isThirdPersonSingular) {
const conj = nlp(followingWord).verbs().conjugate()[0] as
{ Infinitive?: string; PresentTense?: string } | undefined;
if (conj && conj.Infinitive === followingWord && conj.PresentTense) {
return `${resolvedSubject} ${conj.PresentTense}`;
}
}
return `${resolvedSubject} ${followingWord}`;
}
return resolvedSubject;
});
});
return processedSegments.join("");
}

View File

@@ -0,0 +1,3 @@
export * from "./dehydration.js";
export * from "./hydration.js";
export * from "./contractions.js";

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../core" }]
}

142
pnpm-lock.yaml generated
View File

@@ -6,74 +6,74 @@ importers:
configDependencies: {}
packageManagerDependencies:
"@pnpm/exe":
specifier: 11.13.0
version: 11.13.0
specifier: 11.15.1
version: 11.15.1
pnpm:
specifier: 11.13.0
version: 11.13.0
specifier: 11.15.1
version: 11.15.1
packages:
"@pnpm/exe@11.13.0":
"@pnpm/exe@11.15.1":
resolution:
{
integrity: sha512-aXsm4VW1awRuh4X4G4fl+1xdV+L9DElKrgc4Yq/3zgGQQ12ATj1n3MMeosfa68aX/i7E9VyWT42adDSg8+kEFQ==,
integrity: sha512-+e+DQrUPtDqH+9blSre23l7od3P3lRy43HN8TOKHNvbtuTNDRQtqQ0seHk/kn7iJl+ZSKBALFrQXPvbItNgRwA==,
}
hasBin: true
"@pnpm/linux-arm64@11.13.0":
"@pnpm/linux-arm64@11.15.1":
resolution:
{
integrity: sha512-86ri9P/i3Enbv0NBAEtz27lBHHwCJqsC7YxHX56+jXPyFobGbsDVR5OUyoCCVVNz/Phs2O7c9SaDVdqKPzUFCA==,
integrity: sha512-8QOTdBeklXcE/AY4cAR2RJ4809I38kLxtInAYW4sVDfQoWYjmEIoU/ZxpHsk78KM8VU0BehtOB+iZsMxSCyiLQ==,
}
cpu: [arm64]
os: [linux]
"@pnpm/linux-x64@11.13.0":
"@pnpm/linux-x64@11.15.1":
resolution:
{
integrity: sha512-ZXvUMP5ew1WZllTq1f7Tm3l6sF5LK5sMesfdu9J6uxsznK/3tTi6edUeiNN6dA22mAHhjvrzVj+MeQ6X3DG0tA==,
integrity: sha512-hbhuC2Rh5Zw9ERUsQhOSLPnwfWSRTU6phtuFdK2I2raYCXa7v5C4i+XKTH+sKLPG5vJiKPyi4Hd62UECHZoy2w==,
}
cpu: [x64]
os: [linux]
"@pnpm/linuxstatic-arm64@11.13.0":
"@pnpm/linuxstatic-arm64@11.15.1":
resolution:
{
integrity: sha512-elwRj0sE3W54gDECgbHu2/TerpZsHXRmLSXfzuIvReumGuOYk5zlUhD5dJqzp0B1ktetVNU4Rb1keENz5/Gb/Q==,
integrity: sha512-xEL9kDvLXnNYAgQak9tfIhO/BaBf1R/1+/dzzntuquzSqrEaPs0x0P4qMmwq02hVBDfAGA2MAD4n9kmc6Q//tw==,
}
cpu: [arm64]
os: [linux]
libc: [musl]
"@pnpm/linuxstatic-x64@11.13.0":
"@pnpm/linuxstatic-x64@11.15.1":
resolution:
{
integrity: sha512-PQyfFMRA6/uYB+1vqx0wSXfOirC5CUOKRlTjXNgI1q/R1Ug/NMDfKOr52qbWFllcG9pIHKV8hZTo4WyBUjS1mA==,
integrity: sha512-SLJ4neJx649ri4qHdseQN6SnAy6nNFnsLGi7g+EH32cVux4aWX7tjMNTPHrUKJ6Oc7C6oPGXUpLmemEvAwCPOg==,
}
cpu: [x64]
os: [linux]
libc: [musl]
"@pnpm/macos-arm64@11.13.0":
"@pnpm/macos-arm64@11.15.1":
resolution:
{
integrity: sha512-qWf2wbvESMICLJcVfWNh4pwIsr2oK6yFwbuv1uHi0UY3OnWNt0+POmhqWtYWtDj+EPWM3pSfEAc+KFQAiaBCvg==,
integrity: sha512-GbIUQcDZgO/i1Ql38Pk269KNYpEbSCWYcSXBHgT62y8qVTrpEbmi+TKNaLVYuUkK0k/hMibySRa5GUs7/vnNyw==,
}
cpu: [arm64]
os: [darwin]
"@pnpm/win-arm64@11.13.0":
"@pnpm/win-arm64@11.15.1":
resolution:
{
integrity: sha512-ZvbGFdjU3cX32+/sHOhV9VkkbBucJ9QyW/F6VSsqgkvmTZTXlQglYp4K1KBbKWxYKz4i6M9p5PStI6yYB+y0jA==,
integrity: sha512-pLlcpdtoaHJxVfhWahoQg5U7Mv9b6/X/8og+dY1GX1GvsZU3Wf7Y+LlA1sfxqoPcohtQdo6MdJkWiOZ3nIFMCQ==,
}
cpu: [arm64]
os: [win32]
"@pnpm/win-x64@11.13.0":
"@pnpm/win-x64@11.15.1":
resolution:
{
integrity: sha512-5KX2lMFbXZNvKC+gLftR5FiU25j/jMp24Zi9JB01T3r42uxrHt12GQP7CNkNjxCXdi8jioUB7e3iT1eINS8qVQ==,
integrity: sha512-gAvQjBOrNb8zHXRQawBfZRtjDWEFe6p9aSBi8uuMtWB4XAaj0+YuzstsR93llZ/UgP67oTkbkCOITBLayRVGGQ==,
}
cpu: [x64]
os: [win32]
@@ -168,47 +168,47 @@ packages:
}
engines: { node: ">=8" }
pnpm@11.13.0:
pnpm@11.15.1:
resolution:
{
integrity: sha512-iNlHJNjy5sGGdEpVhMblnsrIaex7oV6ctM1ijo3HBmggskgdjuO1HqjaMjpzeAaKpYxVajcg0yt8IKBR0Ig2Og==,
integrity: sha512-gTULB+U8lTigLx8jA7QpD6LXvgTlbiqXDEzEtBfcdh3hlu2r1J1Vx9yVgNuBAHxEFD5OPX5GKzAA0jwlUSLQZQ==,
}
engines: { node: ">=22.13" }
hasBin: true
snapshots:
"@pnpm/exe@11.13.0":
"@pnpm/exe@11.15.1":
dependencies:
"@reflink/reflink": 0.1.19
detect-libc: 2.1.2
optionalDependencies:
"@pnpm/linux-arm64": 11.13.0
"@pnpm/linux-x64": 11.13.0
"@pnpm/linuxstatic-arm64": 11.13.0
"@pnpm/linuxstatic-x64": 11.13.0
"@pnpm/macos-arm64": 11.13.0
"@pnpm/win-arm64": 11.13.0
"@pnpm/win-x64": 11.13.0
"@pnpm/linux-arm64": 11.15.1
"@pnpm/linux-x64": 11.15.1
"@pnpm/linuxstatic-arm64": 11.15.1
"@pnpm/linuxstatic-x64": 11.15.1
"@pnpm/macos-arm64": 11.15.1
"@pnpm/win-arm64": 11.15.1
"@pnpm/win-x64": 11.15.1
"@pnpm/linux-arm64@11.13.0":
"@pnpm/linux-arm64@11.15.1":
optional: true
"@pnpm/linux-x64@11.13.0":
"@pnpm/linux-x64@11.15.1":
optional: true
"@pnpm/linuxstatic-arm64@11.13.0":
"@pnpm/linuxstatic-arm64@11.15.1":
optional: true
"@pnpm/linuxstatic-x64@11.13.0":
"@pnpm/linuxstatic-x64@11.15.1":
optional: true
"@pnpm/macos-arm64@11.13.0":
"@pnpm/macos-arm64@11.15.1":
optional: true
"@pnpm/win-arm64@11.13.0":
"@pnpm/win-arm64@11.15.1":
optional: true
"@pnpm/win-x64@11.13.0":
"@pnpm/win-x64@11.15.1":
optional: true
"@reflink/reflink-darwin-arm64@0.1.19":
@@ -248,7 +248,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.13.0: {}
pnpm@11.15.1: {}
---
lockfileVersion: "9.0"
@@ -284,6 +284,9 @@ importers:
"@types/node":
specifier: ^20.19.43
version: 20.19.43
compromise:
specifier: ^14.16.0
version: 14.16.0
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -354,6 +357,9 @@ importers:
"@omnia/spatial":
specifier: workspace:*
version: link:../../packages/spatial
"@omnia/voice":
specifier: workspace:*
version: link:../../packages/voice
"@radix-ui/react-dialog":
specifier: ^1.1.19
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -439,6 +445,9 @@ importers:
"@omnia/memory":
specifier: workspace:*
version: link:../memory
"@omnia/voice":
specifier: workspace:*
version: link:../voice
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -454,6 +463,9 @@ importers:
"@omnia/llm":
specifier: workspace:*
version: link:../llm
"@omnia/voice":
specifier: workspace:*
version: link:../voice
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -478,6 +490,9 @@ importers:
"@omnia/llm":
specifier: workspace:*
version: link:../llm
"@omnia/voice":
specifier: workspace:*
version: link:../voice
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -506,6 +521,9 @@ importers:
"@omnia/llm":
specifier: workspace:*
version: link:../llm
"@omnia/voice":
specifier: workspace:*
version: link:../voice
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -531,6 +549,15 @@ importers:
specifier: workspace:*
version: link:../core
packages/voice:
dependencies:
"@omnia/core":
specifier: workspace:*
version: link:../core
compromise:
specifier: ^14.14.0
version: 14.16.0
web/docs:
dependencies:
"@astrojs/starlight":
@@ -4716,6 +4743,13 @@ packages:
}
engines: { node: ">= 18" }
compromise@14.16.0:
resolution:
{
integrity: sha512-4DFYl/Hl7sW4XWUDfx9S5vxqyYKpZDwwqrpXsQv5acdbVP+joKceIcIaLb0lhVWUpDBV0OnExk/o/dnYUwXnhQ==,
}
engines: { node: ">=12.0.0" }
conf@10.2.0:
resolution:
{
@@ -5388,6 +5422,13 @@ packages:
integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==,
}
efrt@2.7.0:
resolution:
{
integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==,
}
engines: { node: ">=12.0.0" }
electron-to-chromium@1.5.389:
resolution:
{
@@ -6114,6 +6155,13 @@ packages:
integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==,
}
grad-school@0.0.5:
resolution:
{
integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==,
}
engines: { node: ">=8.0.0" }
groq-sdk@1.3.0:
resolution:
{
@@ -8914,6 +8962,12 @@ packages:
integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==,
}
suffix-thumb@5.0.2:
resolution:
{
integrity: sha512-I5PWXAFKx3FYnI9a+dQMWNqTxoRt6vdBdb0O+BJ1sxXCWtSoQCusc13E58f+9p4MYx/qCnEMkD5jac6K2j3dgA==,
}
supports-color@10.2.2:
resolution:
{
@@ -12520,6 +12574,12 @@ snapshots:
common-ancestor-path@2.0.0: {}
compromise@14.16.0:
dependencies:
efrt: 2.7.0
grad-school: 0.0.5
suffix-thumb: 5.0.2
conf@10.2.0:
dependencies:
ajv: 8.20.0
@@ -12898,6 +12958,8 @@ snapshots:
ee-first@1.1.1: {}
efrt@2.7.0: {}
electron-to-chromium@1.5.389: {}
emoji-regex@10.6.0: {}
@@ -13379,6 +13441,8 @@ snapshots:
graceful-fs@4.2.11: {}
grad-school@0.0.5: {}
groq-sdk@1.3.0: {}
h3@1.15.11:
@@ -15488,6 +15552,8 @@ snapshots:
stylis@4.4.0: {}
suffix-thumb@5.0.2: {}
supports-color@10.2.2: {}
svgo@4.0.1:

View File

@@ -7,7 +7,6 @@ import {
AttributeVisibility,
} from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { IntentSequence } from "@omnia/intent";
import { Architect } from "@omnia/architect";
import { BufferRepository, BufferEntry } from "@omnia/memory";
import {
@@ -56,34 +55,25 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
};
// 2. IntentDecoder splits that prose into 3 intents.
const mockDecodedSequence: IntentSequence = {
const mockDecodedSequence = {
intents: [
{
type: "monologue",
originalText:
"I can't believe Bob hasn't noticed me yet, Alice thought.",
description:
"Alice internally reflects that Bob has not noticed her.",
selfDescription:
"You internally reflect that Bob has not noticed you.",
content: "I internally reflect that Bob has not noticed me.",
actorId: "alice",
targetIds: [],
modifiers: [],
},
{
type: "dialogue",
originalText: '"Hey Bob," she called out softly.',
description: "Alice softly calls out to Bob.",
selfDescription: "You softly call out to Bob.",
content: '"Hey Bob," I call out softly to Bob.',
actorId: "alice",
targetIds: ["bob"],
modifiers: [],
},
{
type: "action",
originalText: "She reached for the ledger on the table.",
description: "Alice reaches for the ledger on the table.",
selfDescription: "You reach for the ledger on the table.",
content: "I reach for the ledger on the table.",
actorId: "alice",
targetIds: [],
modifiers: [],

View File

@@ -1,30 +1,21 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import {
WorldState,
Entity,
SQLiteRepository,
AttributeVisibility,
} from "@omnia/core";
import { WorldState, Entity, SQLiteRepository } from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { IntentDecoder, IntentSequence } from "@omnia/intent";
import { IntentDecoder } from "@omnia/intent";
import { Architect } from "@omnia/architect";
describe("Omnia Integration Tests (Tier 2)", () => {
test("end-to-end intent decoding, validation, execution, and persistence", async () => {
describe("Game Loop Integration Tests (Tier 2)", () => {
test("successfully processes a sequence of dialogue and action intents", async () => {
const db = new Database(":memory:");
const repo = new SQLiteRepository(db);
const startTime = new Date("2026-07-06T12:00:00.000Z");
const world = new WorldState("world-abc", startTime);
world.addAttribute("location", "Dungeon Crawl", AttributeVisibility.PUBLIC);
const alice = new Entity("alice", "room-1");
alice.addAttribute("role", "rogue", AttributeVisibility.PUBLIC);
world.addEntity(alice);
const bob = new Entity("bob", "room-1");
bob.addAttribute("role", "knight", AttributeVisibility.PUBLIC);
world.addEntity(alice);
world.addEntity(bob);
// Save initial state to database
@@ -32,22 +23,18 @@ describe("Omnia Integration Tests (Tier 2)", () => {
// Mock responses for LLM:
// 1. IntentDecoder parses: `"Cover me," Alice whispered to Bob. She crept towards the door and pulled the handle.`
const mockIntentSequence: IntentSequence = {
const mockIntentSequence = {
intents: [
{
type: "dialogue",
originalText: '"Cover me," Alice whispered to Bob.',
description: "Alice whispers to Bob to cover her.",
selfDescription: "You whisper to Bob to cover you.",
content: '"Cover me," I whisper to Bob.',
actorId: "alice",
targetIds: ["bob"],
modifiers: [],
},
{
type: "action",
originalText: "She crept towards the door and pulled the handle.",
description: "Alice creeps to the door and pulls the handle.",
selfDescription: "You creep to the door and pull the handle.",
content: "I creep to the door and pull the handle.",
actorId: "alice",
targetIds: [],
modifiers: [],
@@ -135,9 +122,7 @@ describe("Omnia Integration Tests (Tier 2)", () => {
// 2. Valid dialogue: speaking to bob
const intent1 = {
type: "action" as const,
originalText: "She tries to unlock the gate with a hairpin.",
description: "Alice attempts to pick the lock with a hairpin.",
selfDescription: "You attempt to pick the lock with a hairpin.",
content: "entity@alice[I] attempt to pick the lock with a hairpin.",
actorId: "alice",
targetIds: [],
modifiers: [],
@@ -145,9 +130,7 @@ describe("Omnia Integration Tests (Tier 2)", () => {
const intent2 = {
type: "dialogue" as const,
originalText: '"This is useless," she mutters.',
description: "Alice mutters to herself.",
selfDescription: "You mutter to yourself.",
content: '"This is useless," entity@alice[I] mutter.',
actorId: "alice",
targetIds: [],
modifiers: [],