11 Commits

60 changed files with 7826 additions and 1452 deletions

2
.gitignore vendored
View File

@@ -60,3 +60,5 @@ data/
# Vercel
.vercel
__local_notes/

View File

@@ -122,7 +122,7 @@ Space is a graph: `world → region → location → point of interest`, connect
### Memory Tiers
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
- **Vector Archive (planned):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Vector Archive (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
- **Dossier (planned):** Each observer's subjective beliefs about another character.
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.

24
apps/gui/components.json Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@retroui": "https://retroui.dev/r/radix/{name}.json",
"@retroui-base": "https://retroui.dev/r/base/{name}.json"
}
}

View File

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

View File

@@ -18,18 +18,27 @@
"@omnia/memory": "workspace:*",
"@omnia/scenario": "workspace:*",
"@omnia/spatial": "workspace:*",
"@radix-ui/react-slot": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"lucide-react": "^1.24.0",
"next": "^16.2.10",
"radix-ui": "^1.6.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
"@types/node": "^26.1.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"shadcn": "^4.13.0",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3"
}
}

View File

@@ -1,7 +1,6 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
};

View File

@@ -11,8 +11,29 @@ import {
setProviderMapping,
updateProviderInstance,
getAvailableProviders,
regenerateEmbeddings,
} from "@/app/play/actions";
import type { LLMProviderInstance, LLMProviderMeta } from "@omnia/llm";
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface ConfigStatus {
apiKeySet: boolean;
@@ -23,22 +44,20 @@ interface ConfigStatus {
export default function ConfigPage() {
const [config, setConfig] = useState<ConfigStatus | null>(null);
const [instances, setInstances] = useState<LLMProviderInstance[]>([]);
const [instances, setInstances] = useState<ModelProviderInstance[]>([]);
const [mappings, setMappings] = useState<Record<string, string>>({});
const [availableProviders, setAvailableProviders] = useState<
LLMProviderMeta[]
>([]);
const [availableProviders, setAvailableProviders] = useState<ModelProviderMeta[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(
null,
);
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editProvider, setEditProvider] = useState("google-genai");
const [editKey, setEditKey] = useState("");
const [editModel, setEditModel] = useState("gemini-2.5-flash");
const [editIsActive, setEditIsActive] = useState(false);
const [editType, setEditType] = useState<"generative" | "embedding">("generative");
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
useEffect(() => {
if (selectedInstanceId === null) {
@@ -47,27 +66,29 @@ export default function ConfigPage() {
setEditKey("");
setEditModel("gemini-2.5-flash");
setEditIsActive(false);
setEditType("generative");
setEditMaxContext(32768);
} else if (selectedInstanceId === "new") {
setEditName("");
const defaultProvider = "google-genai";
setEditProvider(defaultProvider);
setEditKey("");
setEditType("generative");
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(false);
setEditMaxContext(32768);
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst) {
setEditName(inst.name);
setEditProvider(inst.providerName);
setEditKey("");
const pMeta = availableProviders.find(
(p) => p.id === inst.providerName,
);
setEditModel(
inst.modelName || pMeta?.defaultModel || "gemini-2.5-flash",
);
setEditType(inst.type || "generative");
const pMeta = availableProviders.find((p) => p.id === inst.providerName);
setEditModel(inst.modelName || (inst.type === "embedding" ? pMeta?.defaultEmbeddingModel : pMeta?.defaultModel) || "gemini-2.5-flash");
setEditIsActive(inst.isActive);
setEditMaxContext(inst.maxContext !== undefined && inst.maxContext !== null ? inst.maxContext : 32768);
}
}
}, [selectedInstanceId, instances, availableProviders]);
@@ -75,35 +96,31 @@ export default function ConfigPage() {
const handleProviderChange = (providerId: string) => {
setEditProvider(providerId);
const pMeta = availableProviders.find((p) => p.id === providerId);
if (pMeta) {
setEditModel(pMeta.defaultModel);
}
setEditModel(editType === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
};
const handleTypeChange = (type: "generative" | "embedding") => {
setEditType(type);
const pMeta = availableProviders.find((p) => p.id === editProvider);
setEditModel(type === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
};
const loadInstances = useCallback(async () => {
try {
const list = await listProviderInstances();
setInstances(list);
} catch {
// ignore
}
const list = await listProviderInstances();
setInstances(list);
}, []);
const loadMappings = useCallback(async () => {
try {
const maps = await getProviderMappings();
setMappings(maps);
} catch {
// ignore
}
const maps = await getProviderMappings();
setMappings(maps);
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await getConfigStatus();
setConfig(result);
setLoading(true);
setError("");
const status = await getConfigStatus();
setConfig(status);
await loadInstances();
await loadMappings();
const provs = await getAvailableProviders();
@@ -130,30 +147,43 @@ export default function ConfigPage() {
setLoading(true);
setError("");
let shouldRegenerate = false;
let targetInstanceId = selectedInstanceId;
if (selectedInstanceId === "new") {
if (!editKey.trim()) {
setError("API Key is required for new instances.");
setLoading(false);
return;
}
const created = await createProviderInstance(
editName,
editProvider,
editKey,
editModel || undefined,
);
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0);
if (editIsActive) {
await setActiveProviderInstance(created.id);
}
targetInstanceId = created.id;
setSelectedInstanceId(created.id);
} else {
await updateProviderInstance(
selectedInstanceId,
editName,
editProvider,
editKey || undefined,
editModel || undefined,
);
if (!selectedInstanceId) return;
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst && inst.type === "embedding") {
const isMapped = mappings["embeddings"] === selectedInstanceId;
const isActive = inst.isActive && !mappings["embeddings"];
if (isMapped || isActive) {
const hasChanged = inst.providerName !== editProvider || inst.modelName !== editModel;
if (hasChanged) {
const confirmChange = window.confirm(
"You have changed the configuration of the active embedding provider. This will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
);
if (!confirmChange) {
setLoading(false);
return;
}
shouldRegenerate = true;
}
}
}
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
}
@@ -161,6 +191,10 @@ export default function ConfigPage() {
await loadInstances();
await loadMappings();
if (shouldRegenerate && targetInstanceId && targetInstanceId !== "new") {
await regenerateEmbeddings(targetInstanceId);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -170,8 +204,7 @@ export default function ConfigPage() {
const handleDelete = async () => {
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
if (!confirm("Are you sure you want to delete this provider instance?"))
return;
if (!confirm("Are you sure you want to delete this provider instance?")) return;
try {
setLoading(true);
@@ -187,13 +220,20 @@ export default function ConfigPage() {
}
};
const handleUpdateMapping = async (
task: string,
providerInstanceId: string,
) => {
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
if (task === "embeddings" && mappings[task] !== providerInstanceId) {
const confirmChange = window.confirm(
"Changing the embeddings provider will delete all existing embeddings and regenerate them from scratch. Are you sure you want to do this?"
);
if (!confirmChange) return;
}
try {
setLoading(true);
await setProviderMapping(task, providerInstanceId);
if (task === "embeddings") {
await regenerateEmbeddings(providerInstanceId);
}
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -224,13 +264,13 @@ export default function ConfigPage() {
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">
Instances
</h3>
<button
<Button
onClick={() => setSelectedInstanceId("new")}
className="cursor-pointer rounded-md bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600"
type="button"
size="sm"
className="bg-emerald-500 text-white hover:bg-emerald-600"
>
+ Add
</button>
</Button>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{instances.length === 0 ? (
@@ -252,11 +292,11 @@ export default function ConfigPage() {
{inst.name}
</div>
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{inst.providerName}</span>
{inst.isActive && (
<span className="rounded-full bg-green-100 px-1.5 py-[1px] text-[0.65rem] font-semibold text-green-700">
<span>{inst.providerName} ({inst.type || "generative"})</span>
{inst.isActive && (
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-green-200">
Active
</span>
</Badge>
)}
</div>
</div>
@@ -272,10 +312,7 @@ export default function ConfigPage() {
Press + to add or select an existing Instance to edit
</div>
) : (
<form
onSubmit={handleSave}
className="flex h-full flex-col justify-between"
>
<form onSubmit={handleSave} className="flex h-full flex-col justify-between">
<div className="flex flex-1 flex-col gap-5 p-6">
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
{selectedInstanceId === "new"
@@ -284,61 +321,53 @@ export default function ConfigPage() {
</h3>
<div className="flex flex-col gap-1.5">
<label
htmlFor="formName"
className="text-xs font-medium text-gray-700"
>
Friendly Name
</label>
<input
<Label htmlFor="formName">Friendly Name</Label>
<Input
id="formName"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="formProvider"
className="text-xs font-medium text-gray-700"
>
Provider Type
</label>
<select
id="formProvider"
value={editProvider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.displayName}
</option>
))}
</select>
<Label>Instance Type</Label>
<Select value={editType} onValueChange={(v) => handleTypeChange(v as "generative" | "embedding")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="generative">Generative (Chat / Text Completion)</SelectItem>
<SelectItem value="embedding">Embedding (Vector generation)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Provider Type</Label>
<Select value={editProvider} onValueChange={handleProviderChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableProviders.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{editProvider && availableProviders.length > 0 && (
<span className="mt-1 block rounded border border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-600">
{
availableProviders.find(
(p) => p.id === editProvider,
)?.description
}
<span className="mt-1 block rounded border border-2 bg-muted px-3 py-2 text-xs text-muted-foreground">
{availableProviders.find((p) => p.id === editProvider)?.description}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="formKey"
className="text-xs font-medium text-gray-700"
>
API Key
</label>
<input
<Label htmlFor="formKey">API Key</Label>
<Input
id="formKey"
type="password"
value={editKey}
@@ -349,65 +378,65 @@ export default function ConfigPage() {
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="formModel"
className="text-xs font-medium text-gray-700"
>
Model Name
</label>
<input
<Label htmlFor="formModel">Model Name</Label>
<Input
id="formModel"
type="text"
value={editModel}
onChange={(e) => setEditModel(e.target.value)}
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
/>
</div>
{editType === "generative" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="formMaxContext">Max Context Length (Tokens, 0 for infinite)</Label>
<Input
id="formMaxContext"
type="number"
value={editMaxContext}
onChange={(e) => setEditMaxContext(parseInt(e.target.value) || 0)}
min={0}
placeholder="e.g. 32768"
/>
</div>
)}
<div className="mt-1 flex flex-row items-center gap-2">
<input
<Checkbox
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
className="h-4 w-4 cursor-pointer"
onCheckedChange={(v) => setEditIsActive(v === true)}
/>
<label
htmlFor="formActive"
className="cursor-pointer text-xs font-medium text-gray-700"
>
<Label htmlFor="formActive" className="cursor-pointer">
Set as Active Instance
</label>
</Label>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
<div className="flex items-center justify-between border-t-2 bg-muted/50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<button
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
className="cursor-pointer rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
>
Delete
</button>
</Button>
)}
</div>
<div>
<button
<Button
type="submit"
disabled={loading}
className="cursor-pointer rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save"}
</button>
</Button>
</div>
</div>
</form>
@@ -425,51 +454,38 @@ export default function ConfigPage() {
</p>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{[
{
key: "actor-prose",
label: "Actor Prose Generation",
desc: "Generates roleplay/narrative prose for Non-Player Characters.",
},
{
key: "llm-validator",
label: "LLM Validator",
desc: "Arbitrates and validates proposed actions against the world state rules.",
},
{
key: "intent-decoder",
label: "Intent Decoder",
desc: "Splits raw prose actions into structured intents (Player and NPC).",
},
{
key: "timedelta",
label: "TimeDelta Generator",
desc: "Calculates the duration of character actions to advance the game clock.",
},
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters.", type: "generative" },
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules.", type: "generative" },
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC).", type: "generative" },
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock.", type: "generative" },
{ key: "handoff", label: "Memory Handoff Engine", desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.", type: "generative" },
{ key: "embeddings", label: "Text Embeddings Generator", desc: "Generates vector embeddings for long-term memory retrieval.", type: "embedding" },
].map((task) => (
<div
key={task.key}
className="flex flex-col justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
className="flex flex-col justify-between gap-3 rounded-lg border-2 bg-card p-4"
>
<div className="flex flex-col gap-1 text-xs">
<strong className="text-sm text-[#111]">
<strong className="text-sm text-foreground">
{task.label}
</strong>
<span className="mt-0.5 text-gray-500">{task.desc}</span>
<span className="mt-0.5 text-muted-foreground">{task.desc}</span>
</div>
<select
value={mappings[task.key] || ""}
onChange={(e) =>
handleUpdateMapping(task.key, e.target.value)
}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs"
className="w-full rounded border-2 bg-input px-2 py-1.5 text-xs shadow-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<option value="">Use Default Provider</option>
{instances.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.providerName})
{inst.isActive ? " [Active]" : ""}
</option>
))}
<option value="">-- Use Active Key (Default) --</option>
{instances
.filter((inst) => (inst.type || "generative") === task.type)
.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
</option>
))}
</select>
</div>
))}
@@ -487,30 +503,26 @@ export default function ConfigPage() {
.
</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
Name
</th>
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
Path
</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Path</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{config.availableScenarios.map((s) => (
<tr key={s.path}>
<td className="border-b border-gray-100 p-2">{s.name}</td>
<td className="border-b border-gray-100 p-2">
<TableRow key={s.path}>
<TableCell>{s.name}</TableCell>
<TableCell>
<code className="font-mono text-xs text-blue-600">
{s.path}
</code>
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
)}
</section>
</div>

View File

@@ -1,10 +1,92 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@plugin "tailwindcss-animate";
@layer base {
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
@custom-variant data-open (&[data-state="open"], &[data-state="active"]);
@custom-variant data-closed (&[data-state="closed"], &[data-state="inactive"]);
@custom-variant data-active (&[data-state="active"], &[data-state="open"]);
@custom-variant data-checked (&[data-state="checked"], &[aria-checked="true"]);
@custom-variant data-horizontal (&[data-orientation="horizontal"]);
@custom-variant data-vertical (&[data-orientation="vertical"]);
@custom-variant data-popup-open (&[data-state="open"]);
@theme inline {
--font-head: var(--font-head);
--font-sans: var(--font-sans);
--radius: var(--radius);
--shadow-xs: 1px 1px 0 0 var(--border);
--shadow-sm: 2px 2px 0 0 var(--border);
--shadow: 3px 3px 0 0 var(--border);
--shadow-md: 4px 4px 0 0 var(--border);
--shadow-lg: 6px 6px 0 0 var(--border);
--shadow-xl: 10px 10px 0 1px var(--border);
--shadow-2xl: 16px 16px 0 1px var(--border);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
:root {
--radius: 0;
--background: #fff7e8;
--foreground: #000;
--card: #fff;
--card-foreground: #000;
--popover: #fff;
--popover-foreground: #000;
--primary: #ffdc58;
--primary-hover: #ffd12e;
--primary-foreground: #000;
--secondary: #000;
--secondary-foreground: #fff;
--muted: #efe7d6;
--muted-foreground: #6b6355;
--accent: #ffe7a3;
--accent-foreground: #000;
--destructive: #e63946;
--destructive-foreground: #fff;
--border: #000;
--input: #fff;
--ring: #000;
}
.dark {
--background: #1a1815;
--foreground: #f5f0e6;
--card: #262320;
--card-foreground: #f5f0e6;
--popover: #262320;
--popover-foreground: #f5f0e6;
--primary: #ffdc58;
--primary-hover: #ffd12e;
--primary-foreground: #000;
--secondary: #3a352f;
--secondary-foreground: #f5f0e6;
--muted: #2e2a24;
--muted-foreground: #b3ac9e;
--accent: #38342b;
--accent-foreground: #f5f0e6;
--destructive: #ff6b6b;
--destructive-foreground: #1a1815;
--border: #000;
--input: #262320;
--ring: #ffdc58;
}

View File

@@ -1,16 +1,30 @@
import type { ReactNode } from "react";
import { Archivo_Black, Space_Grotesk } from "next/font/google";
import { NavBar } from "@/components/nav/NavBar";
import "./globals.css";
const archivoBlack = Archivo_Black({
subsets: ["latin"],
weight: "400",
variable: "--font-head",
display: "swap",
});
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
export const metadata = {
title: "Omnia GUI",
description: "Omnia Narrative Simulation Engine — Web Interface",
title: "Omnia",
description: "Omnia Narrative Simulation Engine",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="min-h-dvh bg-[#fafafa] text-[#111]">
<body className={`${archivoBlack.variable} ${spaceGrotesk.variable} min-h-dvh bg-background text-foreground font-sans`}>
<NavBar />
{children}
</body>

View File

@@ -1,30 +1,33 @@
import Link from "next/link";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
export default function Home() {
return (
<main className="mx-auto max-w-[800px] px-4 py-12">
<h1 className="mb-2 text-3xl">Omnia GUI</h1>
<p className="mb-8 text-gray-500">
<p className="mb-8 text-muted-foreground">
Configuration and gameplay interface for the Omnia simulation engine.
</p>
<div className="flex gap-4">
<Link
href="/play"
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
>
<h2 className="mb-1 text-xl">Play</h2>
<p className="text-sm text-gray-500">
Start a simulation and interact with NPCs
</p>
<Link href="/play" className="flex-1 no-underline">
<Card className="transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]">
<CardHeader>
<CardTitle>Play</CardTitle>
<CardDescription>
Start a simulation and interact with NPCs
</CardDescription>
</CardHeader>
</Card>
</Link>
<Link
href="/config"
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
>
<h2 className="mb-1 text-xl">Config</h2>
<p className="text-sm text-gray-500">
Check environment, API keys, and available scenarios
</p>
<Link href="/config" className="flex-1 no-underline">
<Card className="transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]">
<CardHeader>
<CardTitle>Config</CardTitle>
<CardDescription>
Check environment, API keys, and available scenarios
</CardDescription>
</CardHeader>
</Card>
</Link>
</div>
</main>

View File

@@ -4,7 +4,7 @@ import path from "path";
import fs from "fs";
import { simulationManager } from "@/lib/simulation";
import type { SimSnapshot } from "@/lib/simulation";
import { ProviderManager, LLMProviderInstance, AVAILABLE_PROVIDERS, LLMProviderMeta } from "@omnia/llm";
import { ProviderManager, ModelProviderInstance, AVAILABLE_PROVIDERS, ModelProviderMeta } from "@omnia/llm";
function resolveScenarioPath(relative: string): string {
const cwd = process.cwd();
@@ -233,7 +233,7 @@ export async function deleteSimulation(simId: string): Promise<
}
}
export async function listProviderInstances(): Promise<LLMProviderInstance[]> {
export async function listProviderInstances(): Promise<ModelProviderInstance[]> {
return ProviderManager.list();
}
@@ -242,8 +242,10 @@ export async function createProviderInstance(
providerName: string,
apiKey: string,
modelName?: string,
): Promise<LLMProviderInstance> {
return ProviderManager.create(name, providerName, apiKey, modelName);
type: "generative" | "embedding" = "generative",
maxContext?: number,
): Promise<ModelProviderInstance> {
return ProviderManager.create(name, providerName, apiKey, modelName, type, maxContext);
}
export async function deleteProviderInstance(id: string): Promise<void> {
@@ -260,8 +262,10 @@ export async function updateProviderInstance(
providerName: string,
apiKey?: string,
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
): Promise<void> {
ProviderManager.update(id, name, providerName, apiKey, modelName);
ProviderManager.update(id, name, providerName, apiKey, modelName, type, maxContext);
}
export async function getProviderMappings(): Promise<Record<string, string>> {
@@ -275,6 +279,10 @@ export async function setProviderMapping(
ProviderManager.setMapping(task, providerInstanceId);
}
export async function getAvailableProviders(): Promise<LLMProviderMeta[]> {
export async function getAvailableProviders(): Promise<ModelProviderMeta[]> {
return AVAILABLE_PROVIDERS;
}
export async function regenerateEmbeddings(newProviderInstanceId?: string): Promise<void> {
await simulationManager.regenerateAllEmbeddings(newProviderInstanceId);
}

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
const links = [
{ href: "/", label: "Home" },
@@ -13,57 +14,22 @@ export function NavBar() {
const pathname = usePathname();
return (
<nav className="navbar">
<Link href="/" className="nav-brand">
Omnia
</Link>
<div className="nav-links">
<nav className="flex items-center gap-4 border-b-2 px-4 py-3">
<Button variant="link" asChild className="font-head text-base font-bold no-underline">
<Link href="/">Omnia</Link>
</Button>
<div className="flex gap-1">
{links.map((link) => (
<Link
<Button
key={link.href}
href={link.href}
className={pathname === link.href ? "nav-link active" : "nav-link"}
variant={pathname === link.href ? "default" : "ghost"}
size="sm"
asChild
>
{link.label}
</Link>
<Link href={link.href}>{link.label}</Link>
</Button>
))}
</div>
<style>{`
.navbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
.nav-brand {
font-weight: 700;
font-size: 1rem;
color: #111;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 0.5rem;
}
.nav-link {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
color: #555;
text-decoration: none;
}
.nav-link:hover {
background: #f3f4f6;
color: #111;
}
.nav-link.active {
background: #eff6ff;
color: #2563eb;
font-weight: 500;
}
`}</style>
</nav>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
// Smooth, premium easing for the open/close — fast out of the gate, gentle
// settle. Shared by the panel height and the chevron so they move in lockstep.
const EASE = "ease-[cubic-bezier(0.32,0.72,0,1)]"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col gap-3", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn(
"overflow-hidden rounded border-2 bg-background text-foreground shadow-md transition-shadow duration-200 hover:shadow-sm data-[state=open]:shadow-sm",
className
)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header data-slot="accordion-header" className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left font-head transition-colors hover:bg-muted/50 data-[state=open]:bg-muted/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon
aria-hidden
data-slot="accordion-trigger-icon"
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-300",
EASE
)}
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
// Radix publishes the measured height as `--radix-accordion-content-height`
// and toggles `data-state`. The accordion-down/up keyframes (in
// shadcn-tailwind.css) interpolate height between that var and 0 for a real
// slide open/close — the Base UI variant achieves the same via transitions.
className="group/panel overflow-hidden bg-card font-body text-sm text-muted-foreground data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up"
{...props}
>
<div
className={cn(
"px-4 pt-2 pb-4 transition-[opacity,transform] duration-300 ease-out",
// Fade + nudge the content as the panel opens/closes, synced to the slide.
"group-data-[state=closed]/panel:-translate-y-1 group-data-[state=closed]/panel:opacity-0",
"[&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded border-2 px-2 py-0.5 text-xs font-head font-medium whitespace-nowrap shadow-sm transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive text-destructive-foreground [a]:hover:bg-destructive/90",
outline:
"bg-transparent text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"border-transparent bg-transparent shadow-none hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "border-transparent bg-transparent shadow-none text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded border-2 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-md [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t-2 bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-5 shrink-0 items-center justify-center rounded border-2 bg-input shadow-sm transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-checked:border-border data-checked:bg-primary data-checked:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-foreground/20 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded border-2 bg-popover p-4 text-sm text-popover-foreground shadow-md duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t-2 bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-head text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded border-2 bg-input px-3 py-2 text-sm shadow-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-head font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,167 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded group-data-[viewport=false]/navigation-menu:border-2 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-accent data-active:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded border-2 bg-input py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,17 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
data-slot="spinner"
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto rounded border-2 shadow-md"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b-2", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t-2 bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b-2 transition-colors hover:bg-accent has-aria-expanded:bg-accent data-[state=selected]:bg-accent",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 bg-muted px-2 text-left align-middle font-head font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded p-1 text-muted-foreground group-data-horizontal/tabs:h-11 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "border-2 bg-card shadow-sm",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded border-2 border-transparent px-4 py-2 text-sm font-head font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 group-data-[variant=default]/tabs-list:data-active:border-border group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-primary data-active:text-primary-foreground group-data-[variant=line]/tabs-list:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-1 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-1 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded border-2 bg-input px-3 py-2 text-sm shadow-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -26,6 +26,7 @@ export interface LogEntry {
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
decoderPrompt?: {
systemPrompt: string;
@@ -37,6 +38,7 @@ export interface LogEntry {
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}

View File

@@ -17,7 +17,7 @@ for (const c of envCandidates) {
}
}
import { BufferRepository } from "@omnia/memory";
import { BufferRepository, LedgerRepository, HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import {
ActorAgent,
@@ -25,7 +25,7 @@ import {
IActorProseGenerator,
buildBufferEntryForIntent,
} from "@omnia/actor";
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider } from "@omnia/llm";
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider, IEmbeddingProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, ModelProviderInstance } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type {
@@ -89,6 +89,7 @@ interface SimSession {
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
ledgerRepo: LedgerRepository;
worldInstanceId: string;
scenarioName: string;
scenarioDescription: string;
@@ -101,6 +102,8 @@ interface SimSession {
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
@@ -119,10 +122,16 @@ class SimulationManager {
playEntityName?: string,
providerInstanceId?: string,
): Promise<SimSnapshot> {
const activeInstance = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId)
: ProviderManager.getActive();
let activeInstance: ModelProviderInstance | null = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
: ProviderManager.getActive("generative");
if (!activeInstance) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
}
}
if (!activeInstance) {
return {
id: "",
@@ -147,6 +156,7 @@ class SimulationManager {
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
const worldInstanceId = id;
@@ -212,13 +222,13 @@ class SimulationManager {
}
const list = ProviderManager.list();
const active = ProviderManager.getActive() || activeInstance;
const active = ProviderManager.getActive("generative") || activeInstance;
const mappings = ProviderManager.getMappings();
const resolveProviderForTask = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst) {
if (!inst || inst.type !== "generative") {
inst = active;
}
@@ -226,20 +236,41 @@ class SimulationManager {
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
const instanceName = inst ? inst.name : undefined;
const maxContext = inst ? inst.maxContext : undefined;
if (providerName === "google-genai") {
return new GeminiProvider(key, modelName, instanceName);
return new GeminiProvider(key, modelName, instanceName, maxContext);
} else if (providerName === "openrouter") {
return new OpenRouterProvider(key, modelName, instanceName);
return new OpenRouterProvider(key, modelName, instanceName, maxContext);
} else {
return new MockLLMProvider([]);
}
};
const resolveEmbeddingProvider = (): IEmbeddingProvider => {
const mappedId = mappings["embeddings"];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
if (providerName === "google-genai") {
return new GeminiEmbeddingProvider(key, modelName);
} else {
return new MockEmbeddingProvider(modelName);
}
};
const actorProvider = resolveProviderForTask("actor-prose");
const validatorProvider = resolveProviderForTask("llm-validator");
const decoderProvider = resolveProviderForTask("intent-decoder");
const timedeltaProvider = resolveProviderForTask("timedelta");
const handoffProvider = resolveProviderForTask("handoff");
const embeddingProvider = resolveEmbeddingProvider();
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
@@ -252,9 +283,10 @@ class SimulationManager {
dbPath,
coreRepo,
bufferRepo,
worldInstanceId,
ledgerRepo,
worldInstanceId: worldInstanceId,
scenarioName: scenarioJson.name,
scenarioDescription: scenarioJson.description,
scenarioDescription: scenarioJson.description || "",
turn: 1,
maxTurns: 20,
entities: entityInfos,
@@ -264,6 +296,8 @@ class SimulationManager {
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: [],
@@ -290,6 +324,7 @@ class SimulationManager {
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await this.runAliasResolution(session);
await this.runHandoffResolution(session);
session.aliasDoneForTurn = true;
this.save(session);
return this.snapshot(session);
@@ -347,6 +382,7 @@ class SimulationManager {
const playerActor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
new FixedProseGenerator(prose),
);
@@ -459,7 +495,7 @@ class SimulationManager {
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, 20);
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, session.ledgerRepo, 20);
const { systemPrompt, userContext } = promptBuilder.build(
worldState,
entity,
@@ -489,6 +525,7 @@ class SimulationManager {
const actor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
);
const result = await actor.act(worldState, entity);
@@ -581,6 +618,31 @@ class SimulationManager {
session.coreRepo.saveWorldState(worldState);
}
private async runHandoffResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(
session.worldInstanceId,
);
if (!worldState) throw new Error("World state lost");
const handoffEngine = new HandoffEngine(
session.handoffProvider,
session.embeddingProvider,
session.bufferRepo,
session.ledgerRepo,
);
const entities = Array.from(worldState.entities.values());
for (const entity of entities) {
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
const maxContext = session.handoffProvider.maxContext !== undefined ? session.handoffProvider.maxContext : 32768;
const trigger = checkHandoffTrigger(entity, bufferEntries, worldState.clock.get(), maxContext);
if (trigger !== "none") {
await handoffEngine.runHandoff(entity, bufferEntries, worldState.clock.get());
}
}
}
private async runAliasResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(
session.worldInstanceId,
@@ -648,36 +710,70 @@ class SimulationManager {
}
const list = ProviderManager.list();
const active = ProviderManager.getActive();
const active = ProviderManager.getActive("generative");
const mappings = state.providerMappings || {};
const resolveProviderForTask = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst) {
if (!inst || inst.type !== "generative") {
inst = active;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
}
}
if (!inst) {
throw new Error(`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`);
}
if (inst.providerName === "google-genai") {
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name);
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
} else if (inst.providerName === "openrouter") {
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name);
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
} else {
return new MockLLMProvider([]);
}
};
const resolveEmbeddingProvider = (): IEmbeddingProvider => {
const mappedId = mappings["embeddings"];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create("Default Embed (Env)", "google-genai", envKey, "gemini-embedding-001", "embedding");
}
}
if (!inst) {
throw new Error(`No active Embedding Provider Instance found for task "embeddings". Please configure an embedding key in Settings first.`);
}
if (inst.providerName === "google-genai") {
return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
} else {
return new MockEmbeddingProvider(inst.modelName);
}
};
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const actorProvider = resolveProviderForTask("actor-prose");
const validatorProvider = resolveProviderForTask("llm-validator");
const decoderProvider = resolveProviderForTask("intent-decoder");
const timedeltaProvider = resolveProviderForTask("timedelta");
const handoffProvider = resolveProviderForTask("handoff");
const embeddingProvider = resolveEmbeddingProvider();
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
@@ -690,6 +786,7 @@ class SimulationManager {
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: id,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
@@ -702,6 +799,8 @@ class SimulationManager {
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: state.log || [],
@@ -769,6 +868,53 @@ class SimulationManager {
});
}
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
const dbDir = path.resolve(process.cwd(), "data");
if (!fs.existsSync(dbDir)) return;
const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db"));
const list = ProviderManager.list();
let inst = newProviderInstanceId ? list.find((p) => p.id === newProviderInstanceId) : null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
let embeddingProvider: IEmbeddingProvider;
if (providerName === "google-genai") {
embeddingProvider = new GeminiEmbeddingProvider(key, modelName);
} else {
embeddingProvider = new MockEmbeddingProvider(modelName);
}
for (const file of files) {
const dbPath = path.join(dbDir, file);
const id = file.replace(".db", "");
const activeSession = this.sessions.get(id);
const db = activeSession ? activeSession.db : new Database(dbPath);
try {
const rows = db.prepare(`SELECT id, content FROM ledger_entries`).all() as { id: string; content: string }[];
for (const row of rows) {
const vector = await embeddingProvider.embed(row.content);
const buffer = Buffer.from(new Float32Array(vector).buffer);
db.prepare(`UPDATE ledger_entries SET embedding = ? WHERE id = ?`).run(buffer, row.id);
}
} catch (err) {
console.error(`Failed to regenerate embeddings for ${file}:`, err);
} finally {
if (!activeSession) {
db.close();
}
}
}
}
private save(session: SimSession): void {
const state: SavedState = {
scenarioName: session.scenarioName,

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,11 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@@ -40,6 +40,7 @@
"eslint-config-prettier": "^10.1.8",
"globals": "^17.7.0",
"prettier": "^3.9.4",
"shadcn": "^4.13.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"vitest": "^4.1.9",

View File

@@ -4,11 +4,14 @@ import {
WorldState,
naturalizeTime,
serializeSubjectiveWorldState,
resolveAlias,
} from "@omnia/core";
import {
BufferEntry,
BufferRepository,
serializeSubjectiveBufferEntry,
LedgerEntry,
LedgerRepository,
} from "@omnia/memory";
/**
@@ -37,12 +40,17 @@ export class ActorPromptBuilder {
/**
* @param bufferRepo Used to fetch the actor's recent memory. Optional —
* if absent, the memory section is omitted.
* @param ledgerRepo Used to fetch long-term memories. Optional.
* @param memoryLimit Maximum number of recent buffer entries to inject.
* Defaults to 20.
* @param ledgerLimit Maximum number of long-term memories to retrieve.
* Defaults to 5.
*/
constructor(
private bufferRepo?: BufferRepository,
private ledgerRepo?: LedgerRepository,
private memoryLimit = 20,
private ledgerLimit = 5,
) {}
/**
@@ -81,9 +89,9 @@ Guidelines:
private buildUserContext(worldState: WorldState, entity: Entity): string {
const sections: string[] = [];
const now = worldState.clock.get();
// --- Subjective present time ---
const now = worldState.clock.get();
sections.push(
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
);
@@ -93,30 +101,43 @@ Guidelines:
`=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`,
);
// Fetch recent buffer entries once
let recentEntries: BufferEntry[] = [];
if (this.bufferRepo) {
try {
recentEntries = this.bufferRepo.listForOwner(entity.id);
} catch {}
}
// --- Recent memory ---
const memorySection = this.buildMemorySection(
entity,
worldState.clock.get(),
);
const memorySection = this.buildMemorySection(entity, recentEntries, now);
if (memorySection) {
sections.push(memorySection);
}
// --- Recalled Long-Term memory ---
const ledgerSection = this.buildLedgerSection(
worldState,
entity,
recentEntries,
now,
);
if (ledgerSection) {
sections.push(ledgerSection);
}
return sections.join("\n\n");
}
private buildMemorySection(entity: Entity, now: Date): string | null {
private buildMemorySection(
entity: Entity,
entries: BufferEntry[],
now: Date,
): string | null {
if (!this.bufferRepo) return null;
let entries: BufferEntry[];
try {
entries = this.bufferRepo.listForOwner(entity.id);
} catch {
return null;
}
if (entries.length === 0) {
return `=== YOUR RECENT MEMORY ===\n(You have no memories yet.)`;
return `=== RECENT EVENTS ===\n(No recent events recorded.)`;
}
const recent = entries.slice(-this.memoryLimit);
@@ -136,6 +157,110 @@ Guidelines:
groupedLines.push(` - ${serialized}`);
}
return `=== YOUR RECENT MEMORY ===\n${groupedLines.join("\n")}`;
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`;
}
private buildLedgerSection(
worldState: WorldState,
entity: Entity,
recentBuffer: BufferEntry[],
now: Date,
): string | null {
if (!this.ledgerRepo) return null;
// 1. Get co-located entities (in the same location as entity)
const coLocatedEntityIds: string[] = [];
if (entity.locationId) {
for (const e of worldState.entities.values()) {
if (e.id !== entity.id && e.locationId === entity.locationId) {
coLocatedEntityIds.push(e.id);
}
}
}
// 2. Compute Active Focus entities based on recent interactions (last 10 entries)
const activeFocus = new Set<string>();
const maxFocus = 3;
// We scan the recent buffer entries to see who we recently talked to or who talked to us
for (let i = recentBuffer.length - 1; i >= 0; i--) {
const entry = recentBuffer[i];
const intent = entry.intent;
if (
intent.actorId !== entity.id &&
coLocatedEntityIds.includes(intent.actorId)
) {
activeFocus.add(intent.actorId);
}
for (const targetId of intent.targetIds) {
if (targetId !== entity.id && coLocatedEntityIds.includes(targetId)) {
activeFocus.add(targetId);
}
}
if (activeFocus.size >= maxFocus) break;
}
// If co-located entities is small, auto-focus all of them
if (activeFocus.size < maxFocus && coLocatedEntityIds.length <= maxFocus) {
for (const id of coLocatedEntityIds) {
if (id !== entity.id) {
activeFocus.add(id);
}
}
}
const activeFocusIds = Array.from(activeFocus);
// 3. Retrieve memories using Active Focus
let recalled: LedgerEntry[];
try {
recalled = this.ledgerRepo.retrieve(
entity.id,
entity.locationId,
activeFocusIds,
undefined, // no query embedding for now (Recency + Importance ranking)
now,
this.ledgerLimit,
{ includeAssociativeNeighbors: true },
);
} catch {
return null;
}
if (recalled.length === 0) return null;
// 4. Format them identical to the recent memory format
const groupedLines: string[] = [];
let currentGroup: string | null = null;
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);
}
if (entry.locationId) {
content += ` (at ${entry.locationId})`;
}
if (when !== currentGroup) {
currentGroup = when;
const header = when.charAt(0).toUpperCase() + when.slice(1);
groupedLines.push(header);
}
groupedLines.push(` - ${content}`);
if (entry.quotes && entry.quotes.length > 0) {
for (const quote of entry.quotes) {
groupedLines.push(` Quote: "${quote}"`);
}
}
}
return `=== YOUR MEMORIES ===\n${groupedLines.join("\n")}`;
}
}

View File

@@ -3,6 +3,7 @@ import { ILLMProvider } from "@omnia/llm";
import {
BufferEntry,
BufferRepository,
LedgerRepository,
} from "@omnia/memory";
import {
Intent,
@@ -73,6 +74,7 @@ export class ActorAgent {
constructor(
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
bufferRepo?: BufferRepository,
ledgerRepo?: LedgerRepository,
memoryLimit?: number,
generator?: IActorProseGenerator,
) {
@@ -87,7 +89,7 @@ export class ActorAgent {
decoderProv = llmProvider;
}
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
this.promptBuilder = new ActorPromptBuilder(bufferRepo, ledgerRepo, memoryLimit);
this.decoder = new IntentDecoder(decoderProv);
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
this.llmProvider = actorProv;

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import Database from "better-sqlite3";
import { WorldState, Entity, AttributeVisibility } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { ActorPromptBuilder } from "../src/actor-prompt-builder";
describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
let db: Database.Database;
let bufferRepo: BufferRepository;
let ledgerRepo: LedgerRepository;
beforeEach(() => {
db = new Database(":memory:");
// Core database schemas for testing
db.exec(`
CREATE TABLE objects (
id TEXT PRIMARY KEY
);
`);
db.exec(`
INSERT INTO objects (id) VALUES ('alice'), ('bob'), ('charlie');
`);
bufferRepo = new BufferRepository(db);
ledgerRepo = new LedgerRepository(db);
});
afterEach(() => {
db.close();
});
it("should inject both recent memory and recalled long-term memory with subjective aliases resolved", () => {
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
const alice = new Entity("alice", "tavern");
// Add subjective alias for bob
alice.aliases.set("bob", "Strider");
world.addEntity(alice);
const bob = new Entity("bob", "tavern");
world.addEntity(bob);
// 1. Populate recent buffer memory
bufferRepo.save({
id: "buf1",
ownerId: "alice",
timestamp: "2024-01-10T11:58:00.000Z", // 2 mins ago
locationId: "tavern",
intent: {
type: "dialogue",
actorId: "alice",
targetIds: ["bob"],
originalText: "Hello there",
description: "Alice greets Bob",
},
});
// 2. Populate ledger repository (long-term memory)
ledgerRepo.save({
id: "ledger1",
ownerId: "alice",
timestamp: "2024-01-08T12:00:00.000Z", // 2 days ago
locationId: "tavern",
involvedEntityIds: ["bob"],
content: "alice met bob at the tavern.",
quotes: ["I am a ranger."],
importance: 9,
embedding: [],
});
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
const { userContext } = builder.build(world, alice);
// Check recent memory exists
expect(userContext).toContain("=== RECENT EVENTS ===");
expect(userContext).toContain("Alice greets Bob");
// Check long-term memory exists
expect(userContext).toContain("=== YOUR MEMORIES ===");
// Bob should be resolved to Strider in the ledger content
expect(userContext).toContain("alice met Strider at the tavern.");
expect(userContext).toContain('Quote: "I am a ranger."');
});
it("should not explode if ledger contains no memories or is empty", () => {
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
const alice = new Entity("alice", "tavern");
world.addEntity(alice);
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
const { userContext } = builder.build(world, alice);
expect(userContext).toContain("=== RECENT EVENTS ===");
expect(userContext).not.toContain("=== YOUR MEMORIES ===");
});
});

View File

@@ -0,0 +1,6 @@
import { Entity } from "./entity.js";
export function resolveAlias(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}

View File

@@ -1,5 +1,13 @@
/**
* Monorepo Hygiene Note:
* If a pure function only touches types owned by core (e.g., Entity, WorldState, Attribute),
* it belongs here in the core package (e.g., in a dedicated file like alias.ts), even if
* a higher-level package is currently its only consumer.
*/
export * from "./attribute.js";
export * from "./entity.js";
export * from "./world.js";
export * from "./clock.js";
export * from "./repository.js";
export * from "./alias.js";

View File

@@ -1,6 +1,7 @@
import { AttributableObject, Attribute, serializeAttributes } from "./attribute.js";
import { Entity } from "./entity.js";
import { WorldClock } from "./clock.js";
import { resolveAlias } from "./alias.js";
export class WorldState extends AttributableObject {
/**
@@ -118,19 +119,6 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
return lines.join("\n");
}
/**
* Resolves how a viewer subjectively refers to a target entity.
* - Self → "you"
* - Known (in the viewer's alias map) → the subjective alias
* - Unknown → "an unfamiliar figure"
*
* Mirrors the implementation in @omnia/memory's resolveAlias, inlined here
* to avoid a circular dependency (memory depends on core).
*/
function resolveAliasViewer(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}
/**
* Serializes a single attribute the way a viewer perceives it — name and
@@ -164,7 +152,7 @@ export function serializeSubjectiveWorldState(
}
const lines: string[] = [];
const viewerAlias = resolveAliasViewer(viewer, viewerId);
const viewerAlias = resolveAlias(viewer, viewerId);
// --- World attributes (only those the viewer can see) ---
const worldVisible = worldState.getVisibleAttributesFor(viewerId);
@@ -208,7 +196,7 @@ export function serializeSubjectiveWorldState(
if (coLocated.length > 0) {
lines.push(" Entities present with you:");
for (const e of coLocated) {
const alias = resolveAliasViewer(viewer, e.id);
const alias = resolveAlias(viewer, e.id);
lines.push(` - ${alias}:`);
const eVisible = e.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
@@ -220,7 +208,7 @@ export function serializeSubjectiveWorldState(
if (elsewhere.length > 0) {
lines.push(" Other presences you are aware of (elsewhere):");
for (const e of elsewhere) {
const alias = resolveAliasViewer(viewer, e.id);
const alias = resolveAlias(viewer, e.id);
lines.push(` - ${alias} [elsewhere]`);
}
}

View File

@@ -17,6 +17,7 @@ export interface LLMResponse<T> {
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}
@@ -29,51 +30,63 @@ export interface LLMCallRecord {
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}
export interface ILLMProvider {
providerName: string;
// We use Zod to ensure the generic T matches the schema
maxContext?: number;
generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>>;
lastCalls?: LLMCallRecord[];
}
export interface LLMProviderInstance {
export interface IEmbeddingProvider {
providerName: string;
embed(text: string): Promise<number[]>;
}
export interface ModelProviderInstance {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: boolean;
modelName?: string;
type: "generative" | "embedding";
maxContext?: number;
}
export interface LLMProviderMeta {
export interface ModelProviderMeta {
id: string;
displayName: string;
description: string;
defaultModel: string;
defaultEmbeddingModel: string;
}
export const AVAILABLE_PROVIDERS: LLMProviderMeta[] = [
export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
{
id: "google-genai",
displayName: "Google Gemini",
description: "Official Gemini integration using Google Gen AI SDK",
defaultModel: "gemini-2.5-flash",
defaultEmbeddingModel: "gemini-embedding-001",
},
{
id: "openrouter",
displayName: "OpenRouter",
description: "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
defaultModel: "google/gemini-2.5-flash",
defaultEmbeddingModel: "openai/text-embedding-3-small",
},
{
id: "mock",
displayName: "Mock LLM Provider",
description: "Stateless mock provider for testing and offline development",
defaultModel: "mock",
defaultEmbeddingModel: "mock-embeddings",
},
];

View File

@@ -1,7 +1,7 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { LLMProviderInstance } from "./llm.js";
import type { ModelProviderInstance } from "./llm.js";
let dbPathOverride: string | null = null;
let hasBootstrapped = false;
@@ -51,7 +51,8 @@ function getSettingsDb() {
providerName TEXT NOT NULL,
apiKey TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 0,
modelName TEXT
modelName TEXT,
type TEXT NOT NULL DEFAULT 'generative'
)
`).run();
@@ -61,6 +62,18 @@ function getSettingsDb() {
// ignore
}
try {
db.prepare(`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`).run();
} catch {
// ignore
}
try {
db.prepare(`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`).run();
} catch {
// ignore
}
// Auto-bootstrap environment variables if DB contains 0 instances
try {
if (!hasBootstrapped) {
@@ -68,24 +81,30 @@ function getSettingsDb() {
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInserted = false;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash");
hasInserted = true;
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInserted ? 0 : 1;
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash");
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
}
}
hasBootstrapped = true;
@@ -98,7 +117,7 @@ function getSettingsDb() {
}
export class ProviderManager {
static list(): LLMProviderInstance[] {
static list(): ModelProviderInstance[] {
const db = getSettingsDb();
try {
const rows = db.prepare(`SELECT * FROM provider_instances`).all() as {
@@ -108,6 +127,8 @@ export class ProviderManager {
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}[];
return rows.map((r) => ({
id: r.id,
@@ -116,25 +137,38 @@ export class ProviderManager {
apiKey: r.apiKey,
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
type: (r.type as "generative" | "embedding") || "generative",
maxContext: r.maxContext !== undefined && r.maxContext !== null ? r.maxContext : (r.type === "embedding" ? 0 : 32768),
}));
} finally {
db.close();
}
}
static create(name: string, providerName: string, apiKey: string, modelName?: string): LLMProviderInstance {
static create(
name: string,
providerName: string,
apiKey: string,
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number
): ModelProviderInstance {
const db = getSettingsDb();
try {
const id = "provider-" + Date.now();
const activeCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1`).get() as { count: number };
const activeCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`)
.get(type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, name, providerName, apiKey, isActive, modelName || null);
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, providerName, apiKey, isActive, modelName || null, type, actualMaxContext);
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName };
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName, type, maxContext: actualMaxContext };
} finally {
db.close();
}
@@ -143,11 +177,13 @@ export class ProviderManager {
static delete(id: string): void {
const db = getSettingsDb();
try {
const provider = db.prepare(`SELECT isActive FROM provider_instances WHERE id = ?`).get(id) as { isActive: number } | undefined;
const provider = db.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`).get(id) as { isActive: number; type: string } | undefined;
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
if (provider && provider.isActive === 1) {
const next = db.prepare(`SELECT id FROM provider_instances LIMIT 1`).get() as { id: string } | undefined;
const next = db
.prepare(`SELECT id FROM provider_instances WHERE type = ? LIMIT 1`)
.get(provider.type) as { id: string } | undefined;
if (next) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(next.id);
}
@@ -160,56 +196,126 @@ export class ProviderManager {
static setActive(id: string): void {
const db = getSettingsDb();
try {
db.prepare(`UPDATE provider_instances SET isActive = 0`).run();
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(id);
} finally {
db.close();
}
}
static update(id: string, name: string, providerName: string, apiKey?: string, modelName?: string): void {
const db = getSettingsDb();
try {
if (apiKey && apiKey.trim()) {
db.prepare(`
UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?
WHERE id = ?
`).run(name, providerName, apiKey, modelName || null, id);
} else {
db.prepare(`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?
WHERE id = ?
`).run(name, providerName, modelName || null, id);
const target = db.prepare(`SELECT type FROM provider_instances WHERE id = ?`).get(id) as { type: string } | undefined;
if (target) {
db.prepare(`UPDATE provider_instances SET isActive = 0 WHERE type = ?`).run(target.type);
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(id);
}
} finally {
db.close();
}
}
static getActive(): LLMProviderInstance | null {
static update(
id: string,
name: string,
providerName: string,
apiKey?: string,
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number
): void {
const db = getSettingsDb();
try {
// Query the DB
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1`).get() as {
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
if (apiKey && apiKey.trim()) {
db.prepare(`
UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`).run(name, providerName, apiKey, modelName || null, type, actualMaxContext, id);
} else {
db.prepare(`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`).run(name, providerName, modelName || null, type, actualMaxContext, id);
}
} finally {
db.close();
}
}
static getActive(type: "generative" | "embedding" = "generative"): ModelProviderInstance | null {
const db = getSettingsDb();
try {
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
} | undefined;
if (!row) {
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
}
const retryRow = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
} | undefined;
if (retryRow) {
return {
id: retryRow.id,
name: retryRow.name,
providerName: retryRow.providerName,
apiKey: retryRow.apiKey,
isActive: true,
modelName: retryRow.modelName || undefined,
type: retryRow.type as "generative" | "embedding",
maxContext: retryRow.maxContext !== undefined && retryRow.maxContext !== null ? retryRow.maxContext : (retryRow.type === "embedding" ? 0 : 32768),
};
}
}
// If there's no active row but some rows exist, return the first one as active, or update it
const firstRow = db.prepare(`SELECT * FROM provider_instances LIMIT 1`).get() as {
const firstRow = db.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`).get(type) as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
} | undefined;
if (firstRow) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(firstRow.id);
@@ -220,6 +326,8 @@ export class ProviderManager {
apiKey: firstRow.apiKey,
isActive: true,
modelName: firstRow.modelName || undefined,
type: firstRow.type as "generative" | "embedding",
maxContext: firstRow.maxContext !== undefined && firstRow.maxContext !== null ? firstRow.maxContext : (firstRow.type === "embedding" ? 0 : 32768),
};
}
return null;
@@ -232,10 +340,28 @@ export class ProviderManager {
apiKey: row.apiKey,
isActive: true,
modelName: row.modelName || undefined,
type: (row.type as "generative" | "embedding") || "generative",
maxContext: row.maxContext !== undefined && row.maxContext !== null ? row.maxContext : (row.type === "embedding" ? 0 : 32768),
};
} catch {
// Lock or write issue fallback: return an in-memory active key if env key exists
const googleKey = process.env.GOOGLE_API_KEY;
if (type === "embedding") {
if (googleKey && googleKey.trim()) {
return {
id: "provider-default-env-embed-fallback",
name: "Gemini Embed (Env Fallback)",
providerName: "google-genai",
apiKey: googleKey.trim(),
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
maxContext: 0,
};
}
return null;
}
// generative fallback
if (googleKey && googleKey.trim()) {
return {
id: "provider-default-env-fallback",
@@ -244,6 +370,8 @@ export class ProviderManager {
apiKey: googleKey.trim(),
isActive: true,
modelName: "gemini-2.5-flash",
type: "generative",
maxContext: 32768,
};
}
const openRouterKey = process.env.OPENROUTER_API_KEY;
@@ -255,6 +383,8 @@ export class ProviderManager {
apiKey: openRouterKey.trim(),
isActive: true,
modelName: "google/gemini-2.5-flash",
type: "generative",
maxContext: 32768,
};
}
return null;

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import { ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings } from "@langchain/google-genai";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
@@ -14,15 +14,17 @@ export class GeminiProvider implements ILLMProvider {
private model: ChatGoogleGenerativeAI;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) {
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!key) {
const active = ProviderManager.getActive();
const active = ProviderManager.getActive("generative");
if (active && active.providerName === GeminiProvider.providerId) {
key = active.apiKey;
if (!model) {
@@ -31,6 +33,9 @@ export class GeminiProvider implements ILLMProvider {
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
@@ -79,6 +84,7 @@ export class GeminiProvider implements ILLMProvider {
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({
@@ -90,3 +96,43 @@ export class GeminiProvider implements ILLMProvider {
return { success: true, data: parsed, usage };
}
}
export class GeminiEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "google-genai";
static readonly displayName = "Google Gemini Embeddings";
providerName = "Gemini";
private model: GoogleGenerativeAIEmbeddings;
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive("embedding");
if (active) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
}
}
if (!key) {
key = llmConfig.GOOGLE_API_KEY;
}
if (!key) {
throw new Error("GOOGLE_API_KEY is required to initialize GeminiEmbeddingProvider");
}
this.model = new GoogleGenerativeAIEmbeddings({
apiKey: key,
modelName: model || "gemini-embedding-001",
});
}
async embed(text: string): Promise<number[]> {
return this.model.embedQuery(text);
}
}

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
export class MockLLMProvider implements ILLMProvider {
static readonly providerId = "mock";
@@ -34,3 +34,21 @@ export class MockLLMProvider implements ILLMProvider {
}
}
}
export class MockEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "mock";
providerName = "mock";
constructor(private modelName?: string) {}
async embed(text: string): Promise<number[]> {
// Return a deterministic mock 768-dimensional vector based on the text
const vec = new Array(768).fill(0).map((_, i) => {
// Return a predictable float between -1.0 and 1.0
const charCode = text.charCodeAt(i % text.length) || 0;
return Math.sin(charCode + i);
});
return vec;
}
}

View File

@@ -14,15 +14,17 @@ export class OpenRouterProvider implements ILLMProvider {
private model: ChatOpenRouter;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) {
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!key) {
const active = ProviderManager.getActive();
const active = ProviderManager.getActive("generative");
if (active && active.providerName === OpenRouterProvider.providerId) {
key = active.apiKey;
if (!model) {
@@ -31,6 +33,9 @@ export class OpenRouterProvider implements ILLMProvider {
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
@@ -79,6 +84,7 @@ export class OpenRouterProvider implements ILLMProvider {
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({

View File

@@ -1,6 +1,6 @@
import { describe, test, expect } from "vitest";
import { z } from "zod";
import { MockLLMProvider } from "@omnia/llm";
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
describe("MockLLMProvider Unit Tests (Tier 1)", () => {
test("returns parsed matching data for valid mock response", async () => {
@@ -61,3 +61,21 @@ describe("MockLLMProvider Unit Tests (Tier 1)", () => {
expect(response.data).toBeUndefined();
});
});
describe("MockEmbeddingProvider Unit Tests (Tier 1)", () => {
test("generates deterministic 768-dimensional vectors", async () => {
const provider = new MockEmbeddingProvider("mock-embeddings");
const text = "Hello world";
const vec1 = await provider.embed(text);
const vec2 = await provider.embed(text);
expect(vec1.length).toBe(768);
expect(vec2.length).toBe(768);
expect(vec1).toEqual(vec2); // Deterministic
// Ensure values are numbers between -1.0 and 1.0 (since they are generated with Math.sin)
expect(typeof vec1[0]).toBe("number");
expect(vec1[0]).toBeGreaterThanOrEqual(-1.0);
expect(vec1[0]).toBeLessThanOrEqual(1.0);
});
});

View File

@@ -93,6 +93,7 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
totalTokens: 15,
modelName: "google/gemini-2.5-flash",
providerInstanceName: "Default",
maxContext: 32768,
});
expect(provider.lastCalls.length).toBe(1);
@@ -105,6 +106,7 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
totalTokens: 15,
modelName: "google/gemini-2.5-flash",
providerInstanceName: "Default",
maxContext: 32768,
},
});
});

View File

@@ -47,7 +47,7 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
process.env.OPENROUTER_API_KEY = "mock-openrouter-key-456";
const list = ProviderManager.list();
expect(list.length).toBe(2);
expect(list.length).toBe(3);
const gemini = list.find((p) => p.providerName === "google-genai");
expect(gemini).toBeDefined();
@@ -69,23 +69,27 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
// Trigger bootstrap
const list = ProviderManager.list();
expect(list.length).toBe(1);
const bootstrapped = list[0];
expect(bootstrapped.name).toBe("Gemini (Env)");
expect(list.length).toBe(2);
const bootstrapped = list.find((p) => p.name === "Gemini (Env)");
expect(bootstrapped).toBeDefined();
if (!bootstrapped) return;
expect(bootstrapped.isActive).toBe(true);
// Edit name and key
ProviderManager.update(bootstrapped.id, "My Gemini Key", "google-genai", "new-secret-key", "gemini-2.5-pro");
const listAfterUpdate = ProviderManager.list();
expect(listAfterUpdate.length).toBe(1);
expect(listAfterUpdate[0].name).toBe("My Gemini Key");
expect(listAfterUpdate[0].apiKey).toBe("new-secret-key");
expect(listAfterUpdate[0].modelName).toBe("gemini-2.5-pro");
expect(listAfterUpdate.length).toBe(2);
const updated = listAfterUpdate.find((p) => p.id === bootstrapped.id);
expect(updated).toBeDefined();
if (!updated) return;
expect(updated.name).toBe("My Gemini Key");
expect(updated.apiKey).toBe("new-secret-key");
expect(updated.modelName).toBe("gemini-2.5-pro");
// Delete instance
ProviderManager.delete(bootstrapped.id);
const listAfterDelete = ProviderManager.list();
expect(listAfterDelete.length).toBe(0);
expect(listAfterDelete.length).toBe(1);
});
});

View File

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

View File

@@ -1,5 +1,5 @@
import Database from "better-sqlite3";
import { Entity } from "@omnia/core";
import { Entity, resolveAlias } from "@omnia/core";
import { Intent } from "@omnia/intent";
export interface BufferEntry {
@@ -14,12 +14,10 @@ export interface BufferEntry {
isValid: boolean;
reason: string;
};
pinned?: boolean;
}
export function resolveAlias(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}
export { resolveAlias } from "@omnia/core";
export function serializeSubjectiveBufferEntry(
entry: BufferEntry,
@@ -65,23 +63,31 @@ export class BufferRepository {
location_id TEXT,
intent_json TEXT NOT NULL,
outcome_json TEXT,
pinned INTEGER DEFAULT 0,
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
);
`);
try {
this.db.exec(`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`);
} catch {
// ignore
}
}
save(entry: BufferEntry): void {
this.db
.prepare(
`
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
timestamp = excluded.timestamp,
location_id = excluded.location_id,
intent_json = excluded.intent_json,
outcome_json = excluded.outcome_json
outcome_json = excluded.outcome_json,
pinned = excluded.pinned
`,
)
.run(
@@ -91,6 +97,7 @@ export class BufferRepository {
entry.locationId,
JSON.stringify(entry.intent),
entry.outcome ? JSON.stringify(entry.outcome) : null,
entry.pinned ? 1 : 0,
);
}
@@ -98,7 +105,7 @@ export class BufferRepository {
const row = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
FROM buffer_entries WHERE id = ?
`,
)
@@ -110,6 +117,7 @@ export class BufferRepository {
location_id: string | null;
intent_json: string;
outcome_json: string | null;
pinned?: number;
}
| undefined;
@@ -122,6 +130,7 @@ export class BufferRepository {
locationId: row.location_id,
intent: JSON.parse(row.intent_json),
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
pinned: row.pinned === 1,
};
}
@@ -129,7 +138,7 @@ export class BufferRepository {
const rows = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
FROM buffer_entries WHERE owner_id = ?
ORDER BY timestamp ASC
`,
@@ -141,6 +150,7 @@ export class BufferRepository {
location_id: string | null;
intent_json: string;
outcome_json: string | null;
pinned?: number;
}[];
return rows.map((row) => ({
@@ -150,6 +160,7 @@ export class BufferRepository {
locationId: row.location_id,
intent: JSON.parse(row.intent_json),
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
pinned: row.pinned === 1,
}));
}

View File

@@ -0,0 +1,300 @@
import { z } from "zod";
import { Entity, naturalizeTime } from "@omnia/core";
import { BufferEntry, serializeSubjectiveBufferEntry, BufferRepository } from "./buffer.js";
import { LedgerEntry, LedgerRepository } from "./ledger.js";
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
export const HandoffChunkSchema = z.object({
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
content: z.string(), // third-person summary -> LedgerEntry.content
quotes: z.array(z.string()), // verbatim, high-salience lines only
importance: z.number().int().min(1).max(10),
involvedEntityIds: z.array(z.string()),
retainInBuffer: z.boolean(), // "pin"
});
export const HandoffResultSchema = z.object({
chunks: z.array(HandoffChunkSchema),
});
export type HandoffResult = z.infer<typeof HandoffResultSchema>;
export type HandoffTrigger = "none" | "voluntary" | "involuntary";
/**
* Serializes the hypothetical memory section for size checking.
*/
export function getMemorySectionLength(
entity: Entity,
entries: BufferEntry[],
now: Date,
): number {
if (entries.length === 0) {
return `=== RECENT EVENTS ===\n(No recent events recorded.)`.length;
}
const groupedLines: string[] = [];
let currentGroup: string | null = null;
for (const entry of entries) {
const serialized = serializeSubjectiveBufferEntry(entry, entity);
const when = naturalizeTime(now, new Date(entry.timestamp));
if (when !== currentGroup) {
currentGroup = when;
const header = when.charAt(0).toUpperCase() + when.slice(1);
groupedLines.push(header);
}
groupedLines.push(` - ${serialized}`);
}
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`.length;
}
function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
if (bufferEntries.length === 0) return false;
// Find the location of the most recent buffer entries
const lastEntry = bufferEntries[bufferEntries.length - 1];
if (lastEntry.locationId && entity.locationId && lastEntry.locationId !== entity.locationId) {
return true;
}
// Also check if there are entries from different locations in the buffer
const locations = new Set(bufferEntries.map(e => e.locationId).filter(loc => loc !== null));
if (locations.size > 1) {
return true;
}
return false;
}
function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
const N = 5; // N consecutive idle turns
if (bufferEntries.length < N) return false;
// Check the last N entries
const lastN = bufferEntries.slice(-N);
return lastN.every(e => e.intent.type === "monologue");
}
function checkAttributeTrigger(entity: Entity): boolean {
const consciousness = entity.attributes.get("consciousness");
if (consciousness && consciousness.getValue().toLowerCase() === "unconscious") {
return true;
}
const status = entity.attributes.get("status");
if (status && ["unconscious", "asleep", "dead", "inactive"].includes(status.getValue().toLowerCase())) {
return true;
}
return false;
}
/**
* Checks deterministically whether handoff should run for the given entity.
*/
export function checkHandoffTrigger(
entity: Entity,
bufferEntries: BufferEntry[],
now: Date,
maxContext: number = 32768,
): HandoffTrigger {
if (bufferEntries.length === 0) {
return "none";
}
// Involuntary triggers first (hard)
if (maxContext > 0) {
const memoryLength = getMemorySectionLength(entity, bufferEntries, now);
const charCeiling = maxContext * 4 * 0.60;
if (memoryLength > charCeiling) {
return "involuntary";
}
}
// Event velocity
if (bufferEntries.length > 20) {
return "involuntary";
}
// Voluntary triggers (soft)
if (checkSceneExit(entity, bufferEntries)) {
return "voluntary";
}
if (checkIdleDecay(bufferEntries)) {
return "voluntary";
}
if (checkAttributeTrigger(entity)) {
return "voluntary";
}
return "none";
}
/**
* Splits the buffer into candidate pool (older) and watermark tail (untouched).
*/
export function splitBufferForHandoff(
bufferEntries: BufferEntry[],
now: Date,
K: number = 8,
): { candidates: BufferEntry[]; watermark: BufferEntry[] } {
const sorted = [...bufferEntries].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
const freshBuckets = new Set([
"just now",
"moments ago",
"a few minutes ago",
"several minutes ago",
]);
let watermarkStartIndex = sorted.length;
// 1. Mark last K entries as watermark
if (sorted.length > K) {
watermarkStartIndex = sorted.length - K;
} else {
watermarkStartIndex = 0;
}
// 2. Expand watermark to include any fresh entries before it
for (let i = watermarkStartIndex - 1; i >= 0; i--) {
const bucket = naturalizeTime(now, new Date(sorted[i].timestamp));
if (freshBuckets.has(bucket)) {
watermarkStartIndex = i;
} else {
break;
}
}
return {
candidates: sorted.slice(0, watermarkStartIndex),
watermark: sorted.slice(watermarkStartIndex),
};
}
/**
* HandoffEngine processes memory handoffs using LLM summarization and DB transactions.
*/
export class HandoffEngine {
constructor(
private llmProvider: ILLMProvider,
private embedProvider: IEmbeddingProvider,
private bufferRepo: BufferRepository,
private ledgerRepo: LedgerRepository,
) {}
async runHandoff(
entity: Entity,
bufferEntries: BufferEntry[],
now: Date,
): Promise<boolean> {
const { candidates } = splitBufferForHandoff(bufferEntries, now);
if (candidates.length === 0) {
return false;
}
const candidatesList = candidates.map((entry) => {
const serialized = serializeSubjectiveBufferEntry(entry, entity);
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
}).join("\n---\n");
const systemPrompt = `
You are the memory Handoff Engine. Your task is to process a list of recent working memory buffer entries for an entity and select which memories to promote to the long-term Ledger, and which to forget or summarize.
Instructions:
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. a full back-and-forth conversation or a single physical action and its outcome). Combine them into a single summary chunk.
2. **Write in the third-person** for the "content" of each chunk (e.g. "John asked Mary for the key, and Mary reluctantly handed it over").
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
6. **Retain in Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the working memory buffer for immediate context. Otherwise, set it to false so it is safely pruned from the buffer.
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
`.trim();
const userContext = `
Subject Entity ID: ${entity.id}
Current Time: ${now.toISOString()}
Working Memory Candidates for Handoff:
${candidatesList}
`.trim();
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
userContext,
schema: HandoffResultSchema,
});
if (!response.success || !response.data) {
return false;
}
const result = response.data;
const db = (this.bufferRepo as any).db;
const ledgerEntries: LedgerEntry[] = [];
for (const chunk of result.chunks) {
let embedding: number[] = [];
try {
embedding = await this.embedProvider.embed(chunk.content);
} catch (err) {
console.error("Failed to generate embedding for handoff chunk:", err);
return false;
}
ledgerEntries.push({
id: "ledger-" + Math.random().toString(36).substr(2, 9) + "-" + Date.now(),
ownerId: entity.id,
timestamp: now.toISOString(),
locationId: entity.locationId,
involvedEntityIds: chunk.involvedEntityIds,
content: chunk.content,
quotes: chunk.quotes,
importance: chunk.importance,
embedding,
});
}
try {
db.transaction(() => {
// Save promoted ledger entries
for (const entry of ledgerEntries) {
this.ledgerRepo.save(entry);
}
// Keep track of pinned source IDs
const pinnedSourceIds = new Set<string>();
for (const chunk of result.chunks) {
if (chunk.retainInBuffer) {
for (const id of chunk.sourceEntryIds) {
pinnedSourceIds.add(id);
}
}
}
// Delete or pin entries
for (const candidate of candidates) {
if (pinnedSourceIds.has(candidate.id)) {
const updated = { ...candidate, pinned: true };
this.bufferRepo.save(updated);
} else {
this.bufferRepo.delete(candidate.id);
}
}
})();
return true;
} catch (err) {
console.error("Transaction failed during handoff execution:", err);
return false;
}
}
}

View File

@@ -1 +1,3 @@
export * from "./buffer.js";
export * from "./ledger.js";
export * from "./handoff.js";

View File

@@ -0,0 +1,379 @@
import Database from "better-sqlite3";
export interface LedgerEntry {
id: string;
ownerId: string;
timestamp: string;
locationId: string | null;
involvedEntityIds: string[];
content: string;
quotes: string[];
importance: number;
embedding: number[];
}
export class LedgerRepository {
constructor(private db: Database.Database) {
// Enable foreign keys for cascading deletes
this.db.exec("PRAGMA foreign_keys = ON;");
this.initializeSchema();
}
private initializeSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS ledger_entries (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
location_id TEXT,
content TEXT NOT NULL,
quotes_json TEXT,
importance INTEGER NOT NULL,
embedding BLOB,
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ledger_involved_entities (
entry_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
PRIMARY KEY (entry_id, entity_id),
FOREIGN KEY (entry_id) REFERENCES ledger_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ledger_owner ON ledger_entries(owner_id);
CREATE INDEX IF NOT EXISTS idx_ledger_location ON ledger_entries(location_id);
CREATE INDEX IF NOT EXISTS idx_ledger_importance ON ledger_entries(importance);
CREATE INDEX IF NOT EXISTS idx_ledger_involved_entity ON ledger_involved_entities(entity_id);
`);
}
save(entry: LedgerEntry): void {
const insertEntry = this.db.prepare(`
INSERT INTO ledger_entries (id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
timestamp = excluded.timestamp,
location_id = excluded.location_id,
content = excluded.content,
quotes_json = excluded.quotes_json,
importance = excluded.importance,
embedding = excluded.embedding
`);
const insertEntity = this.db.prepare(`
INSERT OR IGNORE INTO ledger_involved_entities (entry_id, entity_id)
VALUES (?, ?)
`);
const deleteEntities = this.db.prepare(`
DELETE FROM ledger_involved_entities WHERE entry_id = ?
`);
this.db.transaction(() => {
insertEntry.run(
entry.id,
entry.ownerId,
entry.timestamp,
entry.locationId,
entry.content,
JSON.stringify(entry.quotes),
entry.importance,
entry.embedding.length > 0
? Buffer.from(new Float32Array(entry.embedding).buffer)
: null
);
deleteEntities.run(entry.id);
for (const entityId of entry.involvedEntityIds) {
insertEntity.run(entry.id, entityId);
}
})();
}
private mapRowToEntry(row: any, involvedEntityIds: string[]): LedgerEntry {
let embedding: number[] = [];
if (row.embedding) {
const buffer = row.embedding as Buffer;
const floatArray = new Float32Array(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength / Float32Array.BYTES_PER_ELEMENT
);
embedding = Array.from(floatArray);
}
return {
id: row.id,
ownerId: row.owner_id,
timestamp: row.timestamp,
locationId: row.location_id,
involvedEntityIds,
content: row.content,
quotes: JSON.parse(row.quotes_json || "[]"),
importance: row.importance,
embedding: embedding,
};
}
load(id: string): LedgerEntry | null {
const row = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE id = ?
`
)
.get(id) as any;
if (!row) return null;
const entitiesRows = this.db
.prepare(
`
SELECT entity_id FROM ledger_involved_entities WHERE entry_id = ?
`
)
.all(id) as { entity_id: string }[];
return this.mapRowToEntry(row, entitiesRows.map((er) => er.entity_id));
}
/**
* Retrieves relevant ledger entries using Phase 1: Deterministic Heuristic Filtering
* Filters by:
* 1. locationId matches current location
* 2. involvedEntityIds overlaps with current involved entities
* 3. importance >= 8 (high salience)
*/
getRelevant(
ownerId: string,
currentLocationId: string | null,
currentInvolvedEntityIds: string[],
limit: number = 20
): LedgerEntry[] {
let query = `
SELECT DISTINCT le.id, le.owner_id, le.timestamp, le.location_id, le.content, le.quotes_json, le.importance, le.embedding
FROM ledger_entries le
LEFT JOIN ledger_involved_entities lie ON le.id = lie.entry_id
WHERE le.owner_id = ?
AND (
le.importance >= 8
`;
const params: any[] = [ownerId];
if (currentLocationId) {
query += ` OR le.location_id = ?`;
params.push(currentLocationId);
}
if (currentInvolvedEntityIds.length > 0) {
const placeholders = currentInvolvedEntityIds.map(() => "?").join(",");
query += ` OR lie.entity_id IN (${placeholders})`;
params.push(...currentInvolvedEntityIds);
}
query += `
)
ORDER BY le.timestamp DESC
LIMIT ?
`;
params.push(limit);
const rows = this.db.prepare(query).all(...params) as any[];
if (rows.length === 0) return [];
const entryIds = rows.map((r) => r.id);
const placeholders = entryIds.map(() => "?").join(",");
const entitiesRows = this.db
.prepare(
`
SELECT entry_id, entity_id FROM ledger_involved_entities
WHERE entry_id IN (${placeholders})
`
)
.all(...entryIds) as { entry_id: string; entity_id: string }[];
const entitiesMap = new Map<string, string[]>();
for (const er of entitiesRows) {
if (!entitiesMap.has(er.entry_id)) {
entitiesMap.set(er.entry_id, []);
}
entitiesMap.get(er.entry_id)!.push(er.entity_id);
}
return rows.map((row) => this.mapRowToEntry(row, entitiesMap.get(row.id) || []));
}
private fetchRawNeighbors(ownerId: string, timestamp: string): LedgerEntry[] {
const neighbors: LedgerEntry[] = [];
// Preceding entry
const preceding = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE owner_id = ? AND timestamp < ?
ORDER BY timestamp DESC
LIMIT 1
`
)
.get(ownerId, timestamp) as any;
if (preceding) {
neighbors.push(this.mapRowToEntry(preceding, []));
}
// Succeeding entry
const succeeding = this.db
.prepare(
`
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE owner_id = ? AND timestamp > ?
ORDER BY timestamp ASC
LIMIT 1
`
)
.get(ownerId, timestamp) as any;
if (succeeding) {
neighbors.push(this.mapRowToEntry(succeeding, []));
}
return neighbors;
}
/**
* Phase 1 + Phase 2 Retrieval Pipeline
* 1. Fetches candidates via Phase 1 heuristic filtering.
* 2. Ranks them using: Score = Recency + Importance + Semantic Match.
* 3. Selects the top `limit` memories.
* 4. Optionally pulls in the immediate chronological neighbors (associative chain).
* 5. Returns all gathered entries sorted chronologically (timestamp ASC).
*/
retrieve(
ownerId: string,
currentLocationId: string | null,
currentInvolvedEntityIds: string[],
queryEmbedding?: number[],
now: Date = new Date(),
limit: number = 5,
options?: {
includeAssociativeNeighbors?: boolean;
recencyWeight?: number;
importanceWeight?: number;
relevanceWeight?: number;
decayRate?: number;
}
): LedgerEntry[] {
const includeAssociativeNeighbors = options?.includeAssociativeNeighbors ?? false;
const recencyWeight = options?.recencyWeight ?? 1.0;
const importanceWeight = options?.importanceWeight ?? 1.0;
const relevanceWeight = options?.relevanceWeight ?? 1.0;
const decayRate = options?.decayRate ?? 0.99;
// Fetch candidate pool (limit 100 to provide enough options for Phase 2 ranking)
const candidates = this.getRelevant(ownerId, currentLocationId, currentInvolvedEntityIds, 100);
if (candidates.length === 0) return [];
// Score candidates
const scored = candidates.map((entry) => {
// Recency calculation with exponential decay
const deltaMs = now.getTime() - new Date(entry.timestamp).getTime();
const hoursElapsed = Math.max(0, deltaMs / (3600 * 1000));
const recency = Math.pow(decayRate, hoursElapsed);
// Importance score normalized (0.0 to 1.0)
const importanceNorm = entry.importance / 10.0;
// Semantic relevance
let relevance = 0;
if (queryEmbedding && entry.embedding && entry.embedding.length > 0) {
relevance = cosineSimilarity(queryEmbedding, entry.embedding);
}
const score =
recencyWeight * recency +
importanceWeight * importanceNorm +
relevanceWeight * relevance;
return { entry, score };
});
// Rank and take top memories
scored.sort((a, b) => b.score - a.score);
const selected = scored.slice(0, limit).map((s) => s.entry);
let finalEntries = [...selected];
// Optionally retrieve associative neighbors
if (includeAssociativeNeighbors && selected.length > 0) {
const neighborMap = new Map<string, LedgerEntry>();
for (const entry of selected) {
const rawNeighbors = this.fetchRawNeighbors(ownerId, entry.timestamp);
for (const rn of rawNeighbors) {
if (!finalEntries.some((fe) => fe.id === rn.id) && !neighborMap.has(rn.id)) {
neighborMap.set(rn.id, rn);
}
}
}
const neighborsToPopulate = Array.from(neighborMap.values());
if (neighborsToPopulate.length > 0) {
const neighborIds = neighborsToPopulate.map((n) => n.id);
const placeholders = neighborIds.map(() => "?").join(",");
const entitiesRows = this.db
.prepare(
`
SELECT entry_id, entity_id FROM ledger_involved_entities
WHERE entry_id IN (${placeholders})
`
)
.all(...neighborIds) as { entry_id: string; entity_id: string }[];
const entitiesMap = new Map<string, string[]>();
for (const er of entitiesRows) {
if (!entitiesMap.has(er.entry_id)) {
entitiesMap.set(er.entry_id, []);
}
entitiesMap.get(er.entry_id)!.push(er.entity_id);
}
for (const n of neighborsToPopulate) {
n.involvedEntityIds = entitiesMap.get(n.id) || [];
finalEntries.push(n);
}
}
}
// Sort chronologically ASC for the final prompt output
finalEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
return finalEntries;
}
delete(id: string): void {
this.db.prepare(`DELETE FROM ledger_entries WHERE id = ?`).run(id);
}
}
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length || a.length === 0) return 0;
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) return 0;
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}

View File

@@ -0,0 +1,167 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { Entity } from "@omnia/core";
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
import {
BufferEntry,
BufferRepository,
LedgerRepository,
checkHandoffTrigger,
splitBufferForHandoff,
HandoffEngine,
} from "@omnia/memory";
describe("Memory Handoff Tests (Tier 1)", () => {
const now = new Date("2026-07-07T12:00:00.000Z");
test("splitBufferForHandoff correctly splits based on watermark and fresh buckets", () => {
const entries: BufferEntry[] = [];
// Add 12 older entries (older than 30 minutes)
for (let i = 0; i < 12; i++) {
const minutesAgo = 60 - i;
const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000).toISOString();
entries.push({
id: `entry-old-${i}`,
ownerId: "alice",
timestamp,
locationId: "room-1",
intent: {
type: "dialogue",
originalText: `Old event ${i}`,
description: `does old thing ${i}`,
actorId: "alice",
targetIds: ["bob"],
},
});
}
// Add 4 fresh entries (moments ago / just now)
const freshTimes = [
new Date(now.getTime() - 10 * 1000).toISOString(),
new Date(now.getTime() - 30 * 1000).toISOString(),
new Date(now.getTime() - 90 * 1000).toISOString(),
new Date(now.getTime() - 180 * 1000).toISOString(),
];
freshTimes.forEach((timestamp, idx) => {
entries.push({
id: `entry-fresh-${idx}`,
ownerId: "alice",
timestamp,
locationId: "room-1",
intent: {
type: "dialogue",
originalText: `Fresh event ${idx}`,
description: `does fresh thing ${idx}`,
actorId: "alice",
targetIds: ["bob"],
},
});
});
const { candidates, watermark } = splitBufferForHandoff(entries, now, 8);
expect(watermark.length).toBeGreaterThanOrEqual(8);
expect(candidates.length).toBe(8);
expect(candidates[0].id).toBe("entry-old-0");
});
test("checkHandoffTrigger detects scene change and idle decay", () => {
const entity = new Entity("alice");
entity.locationId = "room-2";
// Scenario 1: Empty buffer -> no trigger
expect(checkHandoffTrigger(entity, [], now)).toBe("none");
// Scenario 2: Scene exit
const entryAtRoom1: BufferEntry = {
id: "e-1",
ownerId: "alice",
timestamp: now.toISOString(),
locationId: "room-1",
intent: { type: "dialogue", originalText: "hello", description: "says hello", actorId: "alice", targetIds: [] },
};
expect(checkHandoffTrigger(entity, [entryAtRoom1], now)).toBe("voluntary");
// Scenario 3: Idle decay (5 consecutive monologues)
const monologues: BufferEntry[] = Array.from({ length: 5 }, (_, i) => ({
id: `m-${i}`,
ownerId: "alice",
timestamp: now.toISOString(),
locationId: "room-2",
intent: { type: "monologue", originalText: "think", description: "thinks", actorId: "alice", targetIds: [] },
}));
expect(checkHandoffTrigger(entity, monologues, now)).toBe("voluntary");
});
test("HandoffEngine promotes candidates to Ledger and prunes buffer transactionally", async () => {
const db = new Database(":memory:");
db.exec(`
CREATE TABLE IF NOT EXISTS objects (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL
);
INSERT INTO objects (id, type, name) VALUES ('alice', 'character', 'Alice');
`);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const entity = new Entity("alice");
entity.locationId = "room-1";
const entries: BufferEntry[] = [];
for (let i = 0; i < 10; i++) {
const timestamp = new Date(now.getTime() - (50 - i) * 60 * 1000).toISOString();
const entry: BufferEntry = {
id: `entry-${i}`,
ownerId: "alice",
timestamp,
locationId: "room-1",
intent: {
type: i % 2 === 0 ? "dialogue" : "action",
originalText: `Event ${i}`,
description: `does thing ${i}`,
actorId: "alice",
targetIds: ["bob"],
},
};
bufferRepo.save(entry);
entries.push(entry);
}
const mockHandoffResult = {
chunks: [
{
sourceEntryIds: ["entry-0"],
content: "Alice initiated dialogue and performed various tasks.",
quotes: ["Event 0"],
importance: 5,
involvedEntityIds: ["bob"],
retainInBuffer: false,
},
],
};
const llmProvider = new MockLLMProvider([mockHandoffResult]);
const embedProvider = new MockEmbeddingProvider();
const engine = new HandoffEngine(llmProvider, embedProvider, bufferRepo, ledgerRepo);
const success = await engine.runHandoff(entity, entries, now);
expect(success).toBe(true);
const ledgerRows = db.prepare("SELECT * FROM ledger_entries WHERE owner_id = ?").all("alice") as any[];
expect(ledgerRows.length).toBe(1);
expect(ledgerRows[0].content).toBe("Alice initiated dialogue and performed various tasks.");
expect(JSON.parse(ledgerRows[0].quotes_json)).toEqual(["Event 0"]);
expect(ledgerRows[0].importance).toBe(5);
const remainingBuffer = bufferRepo.listForOwner("alice");
expect(remainingBuffer.length).toBe(8);
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-0");
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-1");
expect(remainingBuffer[0].id).toBe("entry-2");
});
});

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import Database from "better-sqlite3";
import { LedgerRepository, LedgerEntry } from "../src/ledger";
describe("LedgerRepository", () => {
let db: Database.Database;
let repo: LedgerRepository;
beforeEach(() => {
db = new Database(":memory:");
// We need to create a dummy objects table to satisfy foreign keys
db.exec(`
CREATE TABLE objects (
id TEXT PRIMARY KEY
);
`);
db.exec(`
INSERT INTO objects (id) VALUES ('alice'), ('bob'), ('charlie');
`);
repo = new LedgerRepository(db);
});
afterEach(() => {
db.close();
});
it("should save and load a ledger entry", () => {
const entry: LedgerEntry = {
id: "mem1",
ownerId: "alice",
timestamp: new Date().toISOString(),
locationId: "loc1",
involvedEntityIds: ["bob", "charlie"],
content: "Alice met Bob and Charlie at the market.",
quotes: ["Hi guys!"],
importance: 5,
embedding: [0.1, 0.2, 0.3],
};
repo.save(entry);
const loaded = repo.load("mem1");
expect(loaded).toBeDefined();
expect(loaded?.id).toBe("mem1");
expect(loaded?.ownerId).toBe("alice");
expect(loaded?.locationId).toBe("loc1");
expect(loaded?.involvedEntityIds.sort()).toEqual(["bob", "charlie"].sort());
expect(loaded?.content).toBe(entry.content);
expect(loaded?.quotes).toEqual(entry.quotes);
expect(loaded?.importance).toBe(5);
// Check float precision
expect(loaded?.embedding[0]).toBeCloseTo(0.1);
expect(loaded?.embedding[1]).toBeCloseTo(0.2);
expect(loaded?.embedding[2]).toBeCloseTo(0.3);
});
it("should return null for non-existent entry", () => {
const loaded = repo.load("missing");
expect(loaded).toBeNull();
});
it("should retrieve relevant memories based on Phase 1 heuristics", () => {
repo.save({
id: "mem_high_salience",
ownerId: "alice",
timestamp: "2024-01-01T10:00:00.000Z",
locationId: "loc2",
involvedEntityIds: [],
content: "Alice found a magical sword.",
quotes: [],
importance: 9, // high salience
embedding: [],
});
repo.save({
id: "mem_location",
ownerId: "alice",
timestamp: "2024-01-02T10:00:00.000Z",
locationId: "loc1", // matches query
involvedEntityIds: [],
content: "Alice sat on a bench.",
quotes: [],
importance: 2,
embedding: [],
});
repo.save({
id: "mem_social",
ownerId: "alice",
timestamp: "2024-01-03T10:00:00.000Z",
locationId: "loc2",
involvedEntityIds: ["bob"], // matches query
content: "Alice waved at Bob.",
quotes: [],
importance: 3,
embedding: [],
});
repo.save({
id: "mem_irrelevant",
ownerId: "alice",
timestamp: "2024-01-04T10:00:00.000Z",
locationId: "loc3",
involvedEntityIds: ["charlie"],
content: "Alice sneezed.",
quotes: [],
importance: 2,
embedding: [],
});
const relevant = repo.getRelevant("alice", "loc1", ["bob"]);
expect(relevant).toHaveLength(3);
const ids = relevant.map((r) => r.id);
expect(ids).toContain("mem_high_salience"); // due to importance >= 8
expect(ids).toContain("mem_location"); // due to locationId
expect(ids).toContain("mem_social"); // due to involvedEntityIds
expect(ids).not.toContain("mem_irrelevant");
});
it("should retrieve ranked memories with recency, importance, and semantic match", () => {
const now = new Date("2024-01-10T12:00:00.000Z");
repo.save({
id: "mem1",
ownerId: "alice",
timestamp: "2024-01-01T12:00:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice fought a dragon.",
quotes: [],
importance: 10,
embedding: [0, 1, 0],
});
repo.save({
id: "mem2",
ownerId: "alice",
timestamp: "2024-01-10T11:00:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice ate a sandwich.",
quotes: [],
importance: 2,
embedding: [1, 0, 0],
});
repo.save({
id: "mem3",
ownerId: "alice",
timestamp: "2024-01-10T11:50:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice read a book.",
quotes: [],
importance: 5,
embedding: [0.707, 0.707, 0],
});
// Query: [1, 0, 0]
// mem3 score: recency (~0.998) + importance (0.5) + relevance (0.707) = ~2.205
// mem2 score: recency (~0.99) + importance (0.2) + relevance (1.0) = ~2.19
// mem1 score: recency (~0.114) + importance (1.0) + relevance (0.0) = ~1.114
// If limit = 2, should return mem2 and mem3, sorted chronologically (mem2 first, then mem3)
const results = repo.retrieve("alice", "loc1", [], [1, 0, 0], now, 2);
expect(results).toHaveLength(2);
expect(results[0].id).toBe("mem2");
expect(results[1].id).toBe("mem3");
});
it("should pull in associative neighbors when specified", () => {
repo.save({
id: "mem_preceding",
ownerId: "alice",
timestamp: "2024-01-10T10:00:00.000Z",
locationId: "loc_other",
involvedEntityIds: [],
content: "Alice woke up.",
quotes: [],
importance: 2,
embedding: [],
});
repo.save({
id: "mem_target",
ownerId: "alice",
timestamp: "2024-01-10T11:00:00.000Z",
locationId: "loc1",
involvedEntityIds: [],
content: "Alice arrived at tavern.",
quotes: [],
importance: 2,
embedding: [],
});
repo.save({
id: "mem_succeeding",
ownerId: "alice",
timestamp: "2024-01-10T12:00:00.000Z",
locationId: "loc_other",
involvedEntityIds: [],
content: "Alice ordered ale.",
quotes: [],
importance: 2,
embedding: [],
});
// Without neighbors: only returns mem_target
const withoutNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
includeAssociativeNeighbors: false,
});
expect(withoutNeighbors).toHaveLength(1);
expect(withoutNeighbors[0].id).toBe("mem_target");
// With neighbors: returns preceding, target, and succeeding sorted chronologically
const withNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
includeAssociativeNeighbors: true,
});
expect(withNeighbors).toHaveLength(3);
expect(withNeighbors[0].id).toBe("mem_preceding");
expect(withNeighbors[1].id).toBe("mem_target");
expect(withNeighbors[2].id).toBe("mem_succeeding");
});
});

View File

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

View File

@@ -106,7 +106,6 @@ export class ScenarioLoader {
world.addEntity(entity);
this.coreRepo.saveEntity(entity, world.id);
// Seed initial memory buffer history
if (entData.initialMemories) {
for (const mem of entData.initialMemories) {
this.bufferRepo.save({
@@ -114,7 +113,11 @@ export class ScenarioLoader {
ownerId: entData.id,
timestamp: mem.timestamp,
locationId: mem.locationId,
intent: mem.intent,
intent: {
...mem.intent,
selfDescription: mem.intent.selfDescription ?? "",
modifiers: mem.intent.modifiers ?? [],
},
outcome: mem.outcome,
});
}

View File

@@ -33,8 +33,10 @@ export const ScenarioMemoryEntrySchema = z.object({
type: z.enum(["dialogue", "action", "monologue"]),
originalText: z.string(),
description: z.string(),
selfDescription: z.string().optional(),
actorId: z.string(),
targetIds: z.array(z.string()),
modifiers: z.array(z.string()).optional(),
}),
outcome: z.object({
isValid: z.boolean(),

4084
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
---
title: Memory Handoff Pipeline
description: The Tier 1 (Working Buffer) to Tier 2 (Long-Term Ledger) memory promotion architecture
sidebar:
order: 6
---
:::tip[Status: Fully Implemented]
The handoff pipeline is fully integrated into the simulation step loop. The `HandoffEngine` and `checkHandoffTrigger` components process working memory entries, promoting them to the persistent episodic Ledger, while safely pruning the subjective working memory buffer.
:::
The **Handoff** pipeline manages the promotion of subjective experiences from the short-term working memory (Tier 1 Buffer) to long-term episodic memory (Tier 2 Ledger). This process regulates context window utilization by deciding **when** promotion occurs, **how much** of the buffer is processed, and **what** details are preserved or pruned.
To maintain performance and prevent context bloat, the pipeline separates the process into three decoupled stages:
1. **Trigger Detection**: A deterministic, low-cost evaluation of entity and buffer state to determine if a promotion check is required.
2. **Watermark Partitioning**: A policy-driven boundary that separates recent memories (the watermark tail) from older promotion candidates.
3. **Structured Selection**: An LLM-driven synthesis step that summarizes, rates, and prunes candidate memories.
---
## 1. Trigger Detection
Trigger detection runs deterministically at the start of each entity's turn, without LLM overhead. The system categorizes triggers into voluntary (soft) and involuntary (hard) conditions.
```ts
type HandoffTrigger = "none" | "voluntary" | "involuntary";
function checkHandoffTrigger(
entity: Entity,
bufferEntries: BufferEntry[],
now: Date,
maxContext?: number
): HandoffTrigger;
```
### Voluntary Triggers (Soft)
Voluntary triggers identify natural narrative boundaries where the entity is inactive or has transitioned contexts:
* **Scene Exit**: The entity's `locationId` changes relative to the location of the entries currently in the buffer, or the buffer contains entries spanning multiple different locations.
* **Idle Decay**: The entity has produced no external interactions (only monologue entries or no entries) for $N$ (default: 5) consecutive turns.
* **Attribute-Driven State**: A character attribute (e.g., `status: asleep` or `consciousness: unconscious`) signals low external activity.
### Involuntary Triggers (Hard)
Involuntary triggers enforce context constraints before building the next prompt to prevent token limit overflows:
* **Buffer Ceiling**: The serialized length of the working memory section (as generated by `getMemorySectionLength`) exceeds a configured fraction (default: 60%) of the provider's context window.
* **Event Velocity**: The number of buffer entries exceeds a safe threshold (default: 20), indicating an event-heavy scene generating history faster than voluntary boundaries occur.
If the buffer is empty, trigger detection exits immediately with `"none"`.
---
## 2. Watermark Partitioning
To preserve short-term narrative continuity and avoid immediate memory decay, recent events are protected from handoff. The pipeline divides the active buffer into a protected **Watermark Tail** and a **Candidate Pool**.
The watermark boundary is computed as:
$$\text{Watermark} = \max(K \text{ entries}, \text{entries bucketed as fresh by } \textit{naturalizeTime})$$
* **Hard Floor ($K = 8$)**: The last 8 entries in the buffer are always preserved.
* **Temporal Freshness**: Any entries that `naturalizeTime` classifies within the immediate temporal horizon (`"just now"`, `"moments ago"`, `"a few minutes ago"`, or `"several minutes ago"`) are also protected.
Entries older than the watermark boundary constitute the **Candidate Pool** and are eligible for handoff processing.
---
## 3. Structured Selection
Handoff promotion is executed via a single, structured LLM call per run. The engine maps candidate entries to Zod schemas to ensure type-safe integration.
```ts
const HandoffChunkSchema = z.object({
sourceEntryIds: z.array(z.string()), // buffer entries consumed by this chunk
content: z.string(), // third-person summary -> LedgerEntry.content
quotes: z.array(z.string()), // verbatim, high-salience dialogue lines
importance: z.number().int().min(1).max(10),
involvedEntityIds: z.array(z.string()),
retainInBuffer: z.boolean(), // keeps entries in buffer despite promotion
});
const HandoffResultSchema = z.object({
chunks: z.array(HandoffChunkSchema),
});
```
### LLM Processing Rules
The prompt instructs the model to apply the following cognitive operations:
1. **Clustering**: Group related consecutive candidate entries into high-level narrative beats (e.g., consolidating an entire dialogue exchange into one beat).
2. **Third-Person Synthesis**: Write the `content` summary from a third-person narrative perspective.
3. **Dialogue Salience**: Extract verbatim quotes of high emotional or narrative relevance.
4. **Information Pruning (Forgetting)**: Any candidate entry ID omitted from all chunk `sourceEntryIds` is permanently deleted from the buffer and never saved to the ledger. This mirrors natural sensory attenuation.
### Unresolved Thread Retention (Pinning)
When a chunk represents an unresolved high-stakes situation (e.g., an active conflict or standing threat), the LLM sets `retainInBuffer: true`.
* The summary is committed to the long-term Ledger as normal.
* The source buffer entries are exempted from pruning, remaining in the working memory buffer with `pinned: true` until a future handoff pass determines the thread is resolved.
---
## 4. Transactional Execution
To prevent permanent memory loss (e.g., buffer entries being deleted without ledger entries being committed), the handoff execution pipeline operates under a strict **fail-closed** design.
```mermaid
flowchart TD
A[checkHandoffTrigger] -->|none| Z[No-op]
A -->|voluntary or involuntary| B[Partition Buffer]
B --> C{Candidates Available?}
C -->|No| Z
C -->|Yes| D[Inference: LLM Selection & Embedding Generation]
D -->|Failure / Invalid Output| E[Abort - Fail-Closed]
D -->|Success| F[[SQLite Transaction]]
F --> G[(Write Ledger Entries)]
F --> H[(Delete Pruned Buffer Entries)]
F --> I[(Update Pinned Buffer Entries)]
```
If LLM inference, Zod schema validation, or vector embedding generation fails, the process aborts immediately. No database changes are written, and the trigger re-evaluates on the next turn.
---
## 5. Integration
The handoff pipeline is exposed via `HandoffEngine` and is wired directly into the simulation loop:
* **Turn Hook**: `SimulationManager.step()` invokes trigger evaluation and handoff resolution for all active entities at the start of the turn, before entities act.
* **Task Routing**: Handoff tasks are routed using the `"handoff"` routing key, enabling independent LLM provider selection and max context configuration.
* **Buffer Garbage Collection**: By combining memory promotion with automatic pruning, the handoff engine bounds buffer memory usage over long simulation runs, eliminating the need for a separate garbage collection mechanism.

View File

@@ -0,0 +1,140 @@
---
title: Tier 2 Memory (Long-Term Ledger)
description: Persistent episodic memory storage, semantic indexing, and retrieval mechanics
---
**Tier 2 Memory (the Ledger)** represents an entity's long-term episodic memory. It archives historical summaries of past events, providing a persistent record of character experiences that can be retrieved and injected into LLM prompts as needed.
---
## 1. Data Model
A long-term memory is represented by a `LedgerEntry`. It includes metadata for deterministic database-level filtering, structured narrative content, and vector embeddings for semantic similarity scoring.
```ts
interface LedgerEntry {
id: string;
ownerId: string; // The entity to whom this subjective memory belongs
timestamp: string; // ISO timestamp matching the WorldClock at event time
locationId: string | null; // Location where the event transpired
involvedEntityIds: string[]; // Other entity IDs present during the event
content: string; // Third-person narrative summary of the event
quotes: string[]; // Verbatim dialogue lines of high narrative salience
importance: number; // Salience score from 1 (trivial) to 10 (life-altering)
embedding: number[]; // 768-dimensional vector embedding of the content
}
```
---
## 2. Storage Model
To support fast, low-latency queries across large historical datasets, Tier 2 memory is stored in standard SQLite tables. Vector embeddings are stored in raw binary format as `Float32Array` BLOBs.
Standard secondary indices optimize query execution time to microseconds, eliminating the compilation and cross-platform installation overhead of native vector database extensions (such as `sqlite-vec` or `node-gyp` binaries).
```sql
CREATE TABLE IF NOT EXISTS ledger_entries (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
location_id TEXT,
content TEXT NOT NULL,
quotes_json TEXT,
importance INTEGER NOT NULL,
embedding BLOB,
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ledger_involved_entities (
entry_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
PRIMARY KEY (entry_id, entity_id),
FOREIGN KEY (entry_id) REFERENCES ledger_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ledger_owner ON ledger_entries(owner_id);
CREATE INDEX IF NOT EXISTS idx_ledger_location ON ledger_entries(location_id);
CREATE INDEX IF NOT EXISTS idx_ledger_importance ON ledger_entries(importance);
CREATE INDEX IF NOT EXISTS idx_ledger_involved_entity ON ledger_involved_entities(entity_id);
```
---
## 3. The Handoff Pipeline
Working memory (Tier 1 Buffer) entries are promoted to the Ledger through the automated [Handoff Pipeline](./handoff).
During handoff:
1. Candidate entries are clustered into narrative beats.
2. Ambient stage business and redundant details are pruned.
3. Chunks are synthesized into third-person summaries and assigned importance scores.
4. Text embeddings are generated for the summary.
5. The processed memories are committed to the SQLite store, and the short-term buffer is pruned.
---
## 4. Retrieval Architecture
To prevent context window overflow and contain inference costs, memory retrieval is split into a fast database query phase followed by an in-memory ranking phase.
```mermaid
flowchart TD
A[Start Retrieval] --> B[Phase 1: SQL Filter]
B -->|Filter by Location, Co-located Entities, and High Salience| C[Candidate Pool]
C --> D[Phase 2: In-Memory Ranker]
D -->|1. Cosine Similarity| E[Relevance Scores]
D -->|2. Exponential Recency Decay| F[Recency Scores]
E & F --> G[Compute Combined Score]
G --> H[Chronological Associative Chaining]
H --> I[Format Prompt Section]
```
### Phase 1: Deterministic Heuristic Filtering
The primary selection uses indexes to retrieve a candidate pool (capped at 100 entries) from the database:
1. **Spatial Cues**: Fetch entries matching the character's current `locationId`.
2. **Social Cues**: Fetch entries where `involvedEntityIds` intersects with entities currently inside the character's perception radius.
3. **High Salience**: Always retrieve high-salience entries where `importance >= 8`.
### Phase 2: Semantic & Episodic Ranking
Once the candidate pool is loaded, the ranking engine evaluates entries in application memory:
1. **Semantic Similarity**: Cosine similarity is computed directly in JS/TS memory between the current prompt context and the candidate embeddings.
2. **Multi-Factor Scoring**: Candidates are ranked using a weighted linear combination:
$$\text{Score} = (\alpha \times \text{recency}) + (\beta \times \text{importance}) + (\gamma \times \text{relevance})$$
* **Recency** is modeled via exponential decay based on elapsed simulation hours: $\text{decayRate}^{\text{hoursElapsed}}$.
* **Importance** is the normalized salience score (1-10) assigned during handoff.
* **Relevance** is the cosine similarity score.
3. **Chronological Associative Chaining**: When a memory is selected, the system automatically pulls in its adjacent chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity in the prompt.
---
## 5. Active Focus & Attention Loop
In crowded settings (e.g., a room with many characters), retrieving long-term memories for all co-located entities would exhaust the prompt context window. To prevent this, retrieval uses an **Active Focus** selection strategy:
* **Active Focus Set**: The prompt builder scans the last 10 entries of the character's working memory buffer. Any entity targeted by, spoken to, or mentioned in these entries is added to the Active Focus set.
* **Dynamic Capacity Limits**:
* If the number of co-located characters is small ($\le 3$), long-term memory is retrieved for all of them.
* If the environment is crowded ($> 3$), long-term retrieval is strictly restricted to the top 3 characters in the Active Focus set.
* This creates a realistic attention loop: when a new character interacts with the actor, they enter the working buffer, triggering the retrieval of their long-term history on the subsequent turn.
---
## 6. Prompt Formatting
Retrieved ledger entries are formatted into the prompt chronologically and mapped to subjective aliases. Internal metrics (e.g., importance numbers and raw system IDs) are omitted to preserve immersion.
* Working memory entries are injected under `=== RECENT EVENTS ===` (narrated relative to the present moment).
* Recalled ledger entries are injected under `=== YOUR MEMORIES ===` (presented as long-term recollections).
```text
=== RECENT EVENTS ===
Moments ago
- You spoke to Strider: "Hello there"
=== YOUR MEMORIES ===
A couple days ago
- You met a hooded figure named Strider at The Prancing Pony.
Quote: "I can avoid being seen, if I wish, but to disappear entirely, that is a rare gift."
```