mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
Compare commits
7 Commits
0512be647c
...
ee25bf4a4c
| Author | SHA1 | Date | |
|---|---|---|---|
| ee25bf4a4c | |||
|
|
1e34becec7 | ||
|
|
f8977a14c6 | ||
| c2926261a1 | |||
| 5c3a79e8b6 | |||
| a4b620502a | |||
| 84bff92631 |
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
@@ -191,25 +192,40 @@ export function HandoffModal({ entry, onClose }: HandoffModalProps) {
|
||||
)}
|
||||
|
||||
{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" && (
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
Alert,
|
||||
AlertAction,
|
||||
@@ -17,9 +18,13 @@ import {
|
||||
function IntentTag({
|
||||
intent,
|
||||
isSelf,
|
||||
playerAliases,
|
||||
playerId,
|
||||
}: {
|
||||
intent: SimSnapshot["log"][number]["intents"][number];
|
||||
isSelf?: boolean;
|
||||
playerAliases: Record<string, string>;
|
||||
playerId: string;
|
||||
}) {
|
||||
const labels: Record<string, string> = {
|
||||
monologue: "thought",
|
||||
@@ -35,10 +40,13 @@ function IntentTag({
|
||||
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
|
||||
}
|
||||
|
||||
const textToDisplay =
|
||||
isSelf && intent.selfDescription
|
||||
? intent.selfDescription
|
||||
: intent.description;
|
||||
const viewerAliasesMap = new Map(Object.entries(playerAliases || {}));
|
||||
const viewerEntityMock = {
|
||||
id: playerId || "",
|
||||
aliases: viewerAliasesMap,
|
||||
};
|
||||
|
||||
const textToDisplay = hydrate(intent.content, viewerEntityMock as any);
|
||||
|
||||
const modifiersStr =
|
||||
intent.modifiers && intent.modifiers.length > 0 ? (
|
||||
@@ -76,10 +84,14 @@ function LogEntryCard({
|
||||
entry,
|
||||
onShowPrompt,
|
||||
isPlayerCard,
|
||||
playerAliases,
|
||||
playerId,
|
||||
}: {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
isPlayerCard: boolean;
|
||||
playerAliases: Record<string, string>;
|
||||
playerId: string;
|
||||
}) {
|
||||
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
|
||||
|
||||
@@ -117,7 +129,13 @@ function LogEntryCard({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
|
||||
{entry.intents.map((intent, i) => (
|
||||
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
|
||||
<IntentTag
|
||||
key={i}
|
||||
intent={intent}
|
||||
isSelf={isPlayerCard}
|
||||
playerAliases={playerAliases}
|
||||
playerId={playerId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,12 +202,17 @@ 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
172
apps/gui/src/components/play/PromptAnalyzer.tsx
Normal file
172
apps/gui/src/components/play/PromptAnalyzer.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"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 getColorClass = (type: string) => {
|
||||
switch (type) {
|
||||
case "system":
|
||||
return "bg-blue-500";
|
||||
case "world":
|
||||
return "bg-emerald-500";
|
||||
case "events":
|
||||
return "bg-purple-500";
|
||||
case "memories":
|
||||
return "bg-pink-500";
|
||||
case "input":
|
||||
return "bg-amber-500";
|
||||
default:
|
||||
return "bg-slate-500";
|
||||
}
|
||||
};
|
||||
|
||||
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(item.type)}`}
|
||||
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(item.type)}`}
|
||||
/>
|
||||
<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-[300px] 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-[250px] overflow-y-auto">
|
||||
{outputText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -25,177 +20,39 @@ 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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry.rawPrompt && entry.decoderPrompt) {
|
||||
setActiveTab("decoder");
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
// Helper to resolve components with a fallback if none exist (for backwards-compatibility)
|
||||
const getComponents = (
|
||||
promptBreakdown: any,
|
||||
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");
|
||||
|
||||
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">
|
||||
@@ -214,241 +71,37 @@ export function PromptModal({ entry, onClose }: PromptModalProps) {
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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.intents, null, 2)}
|
||||
outputTokens={entry.decoderUsage?.outputTokens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
selfDescription?: string;
|
||||
content: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
@@ -9,6 +8,18 @@ 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 LogEntry {
|
||||
turn: number;
|
||||
entityId: string;
|
||||
@@ -18,10 +29,7 @@ export interface LogEntry {
|
||||
timestamp: string;
|
||||
isHandoff?: boolean;
|
||||
handoffResult?: any;
|
||||
rawPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
rawPrompt?: PromptBreakdown;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -30,10 +38,7 @@ export interface LogEntry {
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
};
|
||||
decoderPrompt?: PromptBreakdown;
|
||||
decoderUsage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -49,6 +54,7 @@ export interface EntityInfo {
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
isAgent: boolean;
|
||||
aliases?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface WaitingContext {
|
||||
|
||||
@@ -39,12 +39,14 @@ export async function runHandoffResolution(session: SimSession): Promise<void> {
|
||||
worldState.clock.get(),
|
||||
);
|
||||
if (ran) {
|
||||
const lastResult = (handoffEngine as any).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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +468,21 @@ 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,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: session.worldInstanceId,
|
||||
status: session.status,
|
||||
@@ -475,7 +490,7 @@ 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,
|
||||
|
||||
@@ -58,8 +58,7 @@ async function processIntents(
|
||||
|
||||
intentInfos.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
content: intent.content,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
@@ -166,6 +165,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 +180,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,9 +191,30 @@ 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;
|
||||
}
|
||||
@@ -244,8 +265,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,9 +279,26 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@langchain/openai": "^0.3.17",
|
||||
"@langchain/openrouter": "^0.4.3",
|
||||
"@types/node": "^20.19.43",
|
||||
"compromise": "^14.16.0",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
LedgerEntry,
|
||||
LedgerRepository,
|
||||
} from "@omnia/memory";
|
||||
import { hydrate } from "@omnia/voice";
|
||||
import { PromptComponent } from "@omnia/llm";
|
||||
|
||||
/**
|
||||
* Zod schema for the structured response expected from the actor LLM.
|
||||
@@ -53,16 +55,30 @@ export class ActorPromptBuilder {
|
||||
private ledgerLimit = 5,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Assembles the system prompt and user context for a given entity.
|
||||
*/
|
||||
/**
|
||||
* 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,
|
||||
): { systemPrompt: string; userContext: string } {
|
||||
): {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
components: PromptComponent[];
|
||||
} {
|
||||
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 +92,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 +129,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 +136,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 +200,7 @@ Guidelines:
|
||||
entry.intent.actorId === entity.id &&
|
||||
entry.intent.type === "dialogue"
|
||||
) {
|
||||
serialized = `You said: ${serialized}`;
|
||||
serialized = `I said: ${serialized}`;
|
||||
}
|
||||
|
||||
if (when !== currentGroup) {
|
||||
@@ -250,12 +292,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})`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."');
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../memory" }
|
||||
{ "path": "../memory" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 ValidationResultSchema = z.object({
|
||||
isValid: z.boolean(),
|
||||
@@ -60,6 +61,8 @@ You must respond with a JSON object containing:
|
||||
- "reason": a concise explanation of why the action is allowed or denied.
|
||||
`.trim();
|
||||
|
||||
const objectiveContent = hydrateObjective(intent.content, worldState);
|
||||
|
||||
const userContext = `
|
||||
=== CURRENT WORLD STATE ===
|
||||
Current Time: ${worldState.clock.get().toISOString()}
|
||||
@@ -69,8 +72,7 @@ ${serializedWorld}
|
||||
=== PROPOSED ACTION ===
|
||||
Actor ID: ${intent.actorId}
|
||||
Type: ${intent.type}
|
||||
Description: "${intent.description}"
|
||||
Original Text: "${intent.originalText}"
|
||||
Content: "${objectiveContent}"
|
||||
Target IDs: ${intent.targetIds.join(", ") || "(None)"}
|
||||
|
||||
Decide if the proposed action is logically valid and physically possible.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
WorldState,
|
||||
Entity,
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
AttributeVisibility,
|
||||
} from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import { Architect, AliasDeltaGenerator } from "../src/index.js";
|
||||
|
||||
describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
test("returns valid response when LLM validates intent as successful", async () => {
|
||||
describe("World Architect Validation Tests (Tier 1)", () => {
|
||||
test("returns valid response when LLM confirms the intent", async () => {
|
||||
const world = new WorldState("world-1");
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
// Setup mock LLM response
|
||||
const mockResponse = {
|
||||
isValid: true,
|
||||
reason: "Alice is in the room and the chest is unlocked.",
|
||||
@@ -25,9 +26,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "open the chest and read the scroll",
|
||||
description: "Open the chest and read the scroll",
|
||||
selfDescription: "You open the chest and read the scroll.",
|
||||
content: "entity@alice[I] open the chest and read the scroll",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -56,9 +55,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "unlock the gate and escape",
|
||||
description: "Unlock the gate and escape",
|
||||
selfDescription: "You unlock the gate and escape.",
|
||||
content: "entity@bob[I] unlock the gate and escape",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -79,9 +76,7 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "haunt the mansion",
|
||||
description: "Haunt the mansion",
|
||||
selfDescription: "You haunt the mansion.",
|
||||
content: "entity@ghost[I] haunt the mansion",
|
||||
actorId: "ghost",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -128,9 +123,7 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "pick the lock of the wooden chest",
|
||||
description: "Pick the lock of the wooden chest",
|
||||
selfDescription: "You pick the lock of the wooden chest.",
|
||||
content: "entity@alice[I] pick the lock of the wooden chest",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -177,9 +170,7 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "run away",
|
||||
description: "Run away",
|
||||
selfDescription: "You run away.",
|
||||
content: "entity@bob[I] run away",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../intent" }
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,34 @@
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
import { dehydrate, expandContractions } from "@omnia/voice";
|
||||
import { Intent, IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
import { IntentDecoderPromptBuilder } from "./intent-prompt-builder.js";
|
||||
|
||||
export class IntentDecoder {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
private promptBuilder: IntentDecoderPromptBuilder;
|
||||
|
||||
constructor(private llmProvider: ILLMProvider) {
|
||||
this.promptBuilder = new IntentDecoderPromptBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes narrative prose into an ordered sequence of structured intents.
|
||||
*
|
||||
* Responsibilities (from docs/intents.md):
|
||||
* - Split prose into multiple intents when applicable.
|
||||
* - Classify each intent as "dialogue", "action", or "monologue".
|
||||
* - Parse narrative text into structured JSON with minimal information loss.
|
||||
* - Contextually resolve receiving parties (targets).
|
||||
*/
|
||||
async decode(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
narrativeProse: string,
|
||||
recentIntents: Intent[] = [],
|
||||
): Promise<IntentSequence> {
|
||||
const entityIds = Array.from(worldState.entities.keys());
|
||||
const processedProse = expandContractions(narrativeProse);
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext =
|
||||
aliasEntries.length > 0
|
||||
? aliasEntries
|
||||
.map(
|
||||
([targetId, alias]) =>
|
||||
`- "${alias}" refers to entity ID: "${targetId}"`,
|
||||
)
|
||||
.join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
|
||||
- "monologue" (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");
|
||||
}
|
||||
|
||||
102
packages/intent/src/intent-prompt-builder.ts
Normal file
102
packages/intent/src/intent-prompt-builder.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { WorldState, resolveAlias } from "@omnia/core";
|
||||
import { PromptBreakdown, PromptComponent } from "@omnia/llm";
|
||||
import { Intent } from "./intent.js";
|
||||
|
||||
// TODO: Builder a generic interface for prompt builders in @omnia/llm: IPromptBuilder or something
|
||||
|
||||
/**
|
||||
* Prompt builder for the Intent Decoder.
|
||||
* Separates prompt generation, structure, and component breakdowns.
|
||||
*/
|
||||
export class IntentDecoderPromptBuilder {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -9,13 +9,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "action",
|
||||
originalText: "Alice opened the chest.",
|
||||
description: "Open the wooden chest.",
|
||||
selfDescription: "You open the wooden chest.",
|
||||
content: "I open the wooden chest.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -34,6 +32,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
expect(result.intents).toHaveLength(1);
|
||||
expect(result.intents[0].type).toBe("action");
|
||||
expect(result.intents[0].actorId).toBe("alice");
|
||||
expect(result.intents[0].content).toContain("entity@alice[I]");
|
||||
expect(result.intents[0].targetIds).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -44,13 +43,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Do you have the key?" Alice asked Bob.',
|
||||
description: "Alice asks Bob if he has the key.",
|
||||
selfDescription: "You ask Bob if he has the key.",
|
||||
content: '"Do you have the key?" I asked Bob.',
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -68,6 +65,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
|
||||
expect(result.intents).toHaveLength(1);
|
||||
expect(result.intents[0].type).toBe("dialogue");
|
||||
expect(result.intents[0].content).toContain("entity@alice[I]");
|
||||
expect(result.intents[0].targetIds).toEqual(["bob"]);
|
||||
});
|
||||
|
||||
@@ -78,21 +76,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
const mockResponse: IntentSequence = {
|
||||
const mockResponse = {
|
||||
intents: [
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob requesting cover.",
|
||||
selfDescription: "You whisper to Bob requesting cover.",
|
||||
content: '"Cover me," I whispered to Bob.',
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Creep towards the door and pull the handle.",
|
||||
selfDescription: "You creep towards the door and pull the handle.",
|
||||
content: "I crept towards the door and pulled the handle.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
@@ -113,6 +107,7 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
expect(result.intents[0].targetIds).toEqual(["bob"]);
|
||||
expect(result.intents[1].type).toBe("action");
|
||||
expect(result.intents[1].actorId).toBe("alice");
|
||||
expect(result.intents[1].content).toContain("entity@alice[I]");
|
||||
});
|
||||
|
||||
test("throws on LLM failure", async () => {
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../core" }, { "path": "../llm" }]
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,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 any[]> {
|
||||
build(...args: TArgs): PromptBreakdown;
|
||||
}
|
||||
|
||||
export interface LLMRequest<T extends z.ZodTypeAny> {
|
||||
systemPrompt: string;
|
||||
userContext: string;
|
||||
|
||||
@@ -127,6 +127,10 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
expect(provider.lastCalls[0]).toEqual({
|
||||
systemPrompt: "system prompt",
|
||||
userContext: "user context",
|
||||
response: {
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
},
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/voice": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity, resolveAlias } 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 {
|
||||
|
||||
59
packages/memory/src/handoff-prompt-builder.ts
Normal file
59
packages/memory/src/handoff-prompt-builder.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Entity } from "@omnia/core";
|
||||
import { PromptBreakdown, PromptComponent } 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 {
|
||||
build(entity: Entity, candidates: BufferEntry[], now: Date): PromptBreakdown {
|
||||
const candidatesList = candidates
|
||||
.map((entry) => {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
|
||||
})
|
||||
.join("\n---\n");
|
||||
|
||||
const systemPrompt = `
|
||||
You are the memory Handoff Engine. Your task is to process a list of Cognitive Buffer entries for an entity and select which memories to promote to the Memory Ledger, and which to forget or summarize.
|
||||
|
||||
Instructions:
|
||||
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. physical action and its outcome or trivial actions). Combine them into a single chunk.
|
||||
2. **Write in the third-person** for the events of other entities. (eg. Alan did that. Sarah did this, etc)
|
||||
2. **Write in first-person for the events that you yourself did. (eg. I did this, I did that.)
|
||||
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
|
||||
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
|
||||
4. Discard small body movements like looking around, sighing, etc that do not contextually hold any meaning after it is done.
|
||||
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
|
||||
6. **Retain in Cognitive Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the Cognitive Buffer for immediate context. Otherwise, set it to false so it is safely pruned from the Cognitive Buffer.
|
||||
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
|
||||
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
|
||||
`.trim();
|
||||
|
||||
const entityContext = `
|
||||
Subject Entity ID: ${entity.id}
|
||||
Current Time: ${now.toISOString()}
|
||||
`.trim();
|
||||
|
||||
const candidatesSection = `Cognitive Buffer Candidates for Handoff:\n${candidatesList}`;
|
||||
|
||||
const userContext = `${entityContext}\n\n${candidatesSection}`;
|
||||
|
||||
const components: PromptComponent[] = [
|
||||
{ label: "System Prompt", type: "system", content: systemPrompt },
|
||||
{ label: "Entity Context", type: "world", content: entityContext },
|
||||
{
|
||||
label: "Cognitive Candidates",
|
||||
type: "input",
|
||||
content: candidatesSection,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
BufferRepository,
|
||||
} from "./buffer.js";
|
||||
import { LedgerEntry, LedgerRepository } from "./ledger.js";
|
||||
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
import { ILLMProvider, IEmbeddingProvider, PromptComponent } from "@omnia/llm";
|
||||
import { HandoffPromptBuilder } from "./handoff-prompt-builder.js";
|
||||
|
||||
export const HandoffChunkSchema = z.object({
|
||||
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
|
||||
@@ -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?: any;
|
||||
}
|
||||
|
||||
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,9 +249,23 @@ ${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;
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
checkHandoffTrigger,
|
||||
splitBufferForHandoff,
|
||||
HandoffEngine,
|
||||
splitBufferForHandoff,
|
||||
checkHandoffTrigger,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
const now = new Date("2026-07-07T12:00:00.000Z");
|
||||
const now = new Date("2026-07-09T08:00:00.000Z");
|
||||
|
||||
test("splitBufferForHandoff correctly splits based on watermark and fresh buckets", () => {
|
||||
describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
test("splitBufferForHandoff identifies candidate entries based on recency", () => {
|
||||
const entries: BufferEntry[] = [];
|
||||
|
||||
// Add 12 older entries (older than 30 minutes)
|
||||
@@ -30,8 +30,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Old event ${i}`,
|
||||
description: `does old thing ${i}`,
|
||||
content: `entity@alice[I] do old thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
@@ -54,8 +53,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Fresh event ${idx}`,
|
||||
description: `does fresh thing ${idx}`,
|
||||
content: `entity@alice[I] do fresh thing ${idx}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
@@ -84,8 +82,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: "hello",
|
||||
description: "says hello",
|
||||
content: "entity@alice[I] say hello",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
@@ -100,8 +97,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-2",
|
||||
intent: {
|
||||
type: "monologue",
|
||||
originalText: "think",
|
||||
description: "thinks",
|
||||
content: "entity@alice[I] think",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
@@ -138,8 +134,7 @@ describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: i % 2 === 0 ? "dialogue" : "action",
|
||||
originalText: `Event ${i}`,
|
||||
description: `does thing ${i}`,
|
||||
content: `entity@alice[I] do thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { Entity, SQLiteRepository } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
serializeSubjectiveBufferEntry,
|
||||
resolveAlias,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
test("resolveAlias correctly handles self and fallbacks", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
|
||||
expect(resolveAlias(viewer, "alice")).toBe("you");
|
||||
expect(resolveAlias(viewer, "bob")).toBe("the hooded figure");
|
||||
expect(resolveAlias(viewer, "charlie")).toBe("an unfamiliar figure");
|
||||
});
|
||||
|
||||
test("serializes dialogue intent substituting target/actor aliases", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
@@ -31,9 +21,8 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: '"Hello there," Bob said to Charlie.',
|
||||
description: "says, 'Hello there' to the bartender",
|
||||
selfDescription: "You say, 'Hello there' to the bartender.",
|
||||
content:
|
||||
"entity@bob[I] say 'Hello there' to entity@charlie[the bartender]",
|
||||
actorId: "bob",
|
||||
targetIds: ["charlie"],
|
||||
modifiers: [],
|
||||
@@ -42,7 +31,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe(
|
||||
"The hooded figure says, 'Hello there' to the bartender",
|
||||
"The hooded figure says 'Hello there' to the bartender",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,9 +46,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Bob tried to break the latch.",
|
||||
description: "attempts to break the lock latch",
|
||||
selfDescription: "You attempt to break the lock latch.",
|
||||
content: "entity@bob[I] attempt to break the lock latch",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -86,9 +73,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "I opened the window.",
|
||||
description: "open the window",
|
||||
selfDescription: "You open the window.",
|
||||
content: "entity@alice[I] open the window",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -96,7 +81,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("You open the window.");
|
||||
expect(resultSelf).toBe("I open the window");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
@@ -105,9 +90,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Someone knocked.",
|
||||
description: "knocks on the door",
|
||||
selfDescription: "You knock on the door.",
|
||||
content: "entity@stranger-1[I] knock on the door",
|
||||
actorId: "stranger-1",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -136,9 +119,7 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "Alice picked up a stick.",
|
||||
description: "Alice gathers a stick",
|
||||
selfDescription: "You gather a stick.",
|
||||
content: "entity@alice[I] gather a stick",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
@@ -196,8 +177,7 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
locationId: "forest",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Alice sneezed.",
|
||||
description: "Alice sneezes",
|
||||
content: "entity@alice[I] sneeze",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" }
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../voice" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
13
packages/voice/package.json
Normal file
13
packages/voice/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
82
packages/voice/src/contractions.ts
Normal file
82
packages/voice/src/contractions.ts
Normal 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("");
|
||||
}
|
||||
145
packages/voice/src/dehydration.ts
Normal file
145
packages/voice/src/dehydration.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
let quoteChar = "";
|
||||
|
||||
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(/[-\/\\^$*+?.()|[\]{}]/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("");
|
||||
}
|
||||
236
packages/voice/src/hydration.ts
Normal file
236
packages/voice/src/hydration.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
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 = "";
|
||||
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 any;
|
||||
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 = "";
|
||||
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 any;
|
||||
if (conj && conj.Infinitive === followingWord && conj.PresentTense) {
|
||||
return `${resolvedSubject} ${conj.PresentTense}`;
|
||||
}
|
||||
}
|
||||
return `${resolvedSubject} ${followingWord}`;
|
||||
}
|
||||
|
||||
return resolvedSubject;
|
||||
});
|
||||
});
|
||||
|
||||
return processedSegments.join("");
|
||||
}
|
||||
3
packages/voice/src/index.ts
Normal file
3
packages/voice/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./dehydration.js";
|
||||
export * from "./hydration.js";
|
||||
export * from "./contractions.js";
|
||||
9
packages/voice/tsconfig.json
Normal file
9
packages/voice/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../core" }]
|
||||
}
|
||||
66
pnpm-lock.yaml
generated
66
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -56,34 +56,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: [],
|
||||
|
||||
@@ -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 { 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: [],
|
||||
|
||||
Reference in New Issue
Block a user