7 Commits

31 changed files with 1089 additions and 1733 deletions

View File

@@ -11,9 +11,8 @@ import {
setProviderMapping,
updateProviderInstance,
getAvailableProviders,
regenerateEmbeddings,
} from "@/app/play/actions";
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
import type { LLMProviderInstance, LLMProviderMeta } from "@omnia/llm";
interface ConfigStatus {
apiKeySet: boolean;
@@ -24,27 +23,35 @@ interface ConfigStatus {
export default function ConfigPage() {
const [config, setConfig] = useState<ConfigStatus | null>(null);
const [instances, setInstances] = useState<ModelProviderInstance[]>([]);
const [instances, setInstances] = useState<LLMProviderInstance[]>([]);
const [mappings, setMappings] = useState<Record<string, string>>({});
const [availableProviders, setAvailableProviders] = useState<ModelProviderMeta[]>([]);
const [availableProviders, setAvailableProviders] = useState<
LLMProviderMeta[]
>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedInstanceId, setSelectedInstanceId] = useState<string | "new">("new");
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");
useEffect(() => {
if (selectedInstanceId === "new") {
if (selectedInstanceId === null) {
setEditName("");
setEditProvider("google-genai");
setEditKey("");
setEditModel("gemini-2.5-flash");
setEditIsActive(false);
} 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);
@@ -54,9 +61,12 @@ export default function ConfigPage() {
setEditName(inst.name);
setEditProvider(inst.providerName);
setEditKey("");
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");
const pMeta = availableProviders.find(
(p) => p.id === inst.providerName,
);
setEditModel(
inst.modelName || pMeta?.defaultModel || "gemini-2.5-flash",
);
setEditIsActive(inst.isActive);
}
}
@@ -66,15 +76,7 @@ export default function ConfigPage() {
setEditProvider(providerId);
const pMeta = availableProviders.find((p) => p.id === providerId);
if (pMeta) {
setEditModel(editType === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
}
};
const handleTypeChange = (type: "generative" | "embedding") => {
setEditType(type);
const pMeta = availableProviders.find((p) => p.id === editProvider);
if (pMeta) {
setEditModel(type === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
setEditModel(pMeta.defaultModel);
}
};
@@ -128,42 +130,30 @@ 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, editType);
const created = await createProviderInstance(
editName,
editProvider,
editKey,
editModel || undefined,
);
if (editIsActive) {
await setActiveProviderInstance(created.id);
}
targetInstanceId = created.id;
setSelectedInstanceId(created.id);
} else {
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);
await updateProviderInstance(
selectedInstanceId,
editName,
editProvider,
editKey || undefined,
editModel || undefined,
);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
}
@@ -171,10 +161,6 @@ export default function ConfigPage() {
await loadInstances();
await loadMappings();
if (shouldRegenerate && targetInstanceId !== "new") {
await regenerateEmbeddings(targetInstanceId);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -183,14 +169,15 @@ export default function ConfigPage() {
};
const handleDelete = async () => {
if (selectedInstanceId === "new") return;
if (!confirm("Are you sure you want to delete this provider instance?")) return;
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
if (!confirm("Are you sure you want to delete this provider instance?"))
return;
try {
setLoading(true);
setError("");
await deleteProviderInstance(selectedInstanceId);
setSelectedInstanceId("new");
setSelectedInstanceId(null);
await loadInstances();
await loadMappings();
} catch (err) {
@@ -200,20 +187,13 @@ export default function ConfigPage() {
}
};
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;
}
const handleUpdateMapping = async (
task: string,
providerInstanceId: string,
) => {
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));
@@ -226,22 +206,24 @@ export default function ConfigPage() {
<div className="mx-auto max-w-[800px] px-4 py-8">
<h1 className="mb-6 text-2xl">Configuration</h1>
{loading && <p>Loading configuration...</p>}
{config === null && loading && <p>Loading configuration...</p>}
{error && (
<div className="mb-4 rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{config && !loading && (
<>
<section className="mb-8 border-b border-gray-200 pb-6">
{config && (
<div className={loading ? "opacity-60 pointer-events-none transition-opacity duration-200" : "transition-opacity duration-200"}>
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
{/* 30% area */}
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">Instances</h3>
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">
Instances
</h3>
<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"
@@ -266,9 +248,11 @@ export default function ConfigPage() {
: "border-l-transparent"
}`}
>
<div className="text-sm font-medium text-[#111]">{inst.name}</div>
<div className="text-sm font-medium text-[#111]">
{inst.name}
</div>
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{inst.providerName} ({inst.type || "generative"})</span>
<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">
Active
@@ -283,213 +267,224 @@ export default function ConfigPage() {
{/* 70% area */}
<div className="flex flex-col bg-white">
<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"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="flex flex-col gap-1.5">
<label htmlFor="formName" className="text-xs font-medium text-gray-700">
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="formType" className="text-xs font-medium text-gray-700">
Instance Type
</label>
<select
id="formType"
value={editType}
onChange={(e) => handleTypeChange(e.target.value as "generative" | "embedding")}
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"
>
<option value="generative">Generative (Chat / Text Completion)</option>
<option value="embedding">Embedding (Vector generation)</option>
</select>
</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>
{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>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="formKey" className="text-xs font-medium text-gray-700">
API Key
</label>
<input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (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
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>
<div className="mt-1 flex flex-row items-center gap-2">
<input
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<label htmlFor="formActive" className="cursor-pointer text-xs font-medium text-gray-700">
Set as Active Instance
</label>
</div>
{selectedInstanceId === null ? (
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center text-sm text-gray-400">
Press + to add or select an existing Instance to edit
</div>
) : (
<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"
? "Create New Provider Instance"
: `Configure: ${editName}`}
</h3>
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<button
type="button"
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"
<div className="flex flex-col gap-1.5">
<label
htmlFor="formName"
className="text-xs font-medium text-gray-700"
>
Delete
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>
{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>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="formKey"
className="text-xs font-medium text-gray-700"
>
API Key
</label>
<input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (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
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>
<div className="mt-1 flex flex-row items-center gap-2">
<input
id="formActive"
type="checkbox"
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<label
htmlFor="formActive"
className="cursor-pointer text-xs font-medium text-gray-700"
>
Set as Active Instance
</label>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
<div>
{selectedInstanceId !== "new" && (
<button
type="button"
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>
)}
</div>
<div>
<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>
)}
</div>
</div>
<div>
<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>
</div>
</div>
</form>
</form>
)}
</div>
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">Task Provider Routing</h2>
<p className="my-4 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
Configure which LLM Provider Key Instance should handle each specific simulation
task. Mappings default to the currently <strong>Active</strong> instance if not
specified.
Configure which LLM Provider Key Instance should handle each
specific simulation task. Mappings default to the currently{" "}
<strong>Active</strong> instance if not specified.
</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.", 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: "embeddings", label: "Text Embeddings Generator", desc: "Generates vector embeddings for long-term memory retrieval.", type: "embedding" },
{
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.",
},
].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"
>
<div className="flex flex-col gap-1 text-xs">
<strong className="text-sm text-[#111]">{task.label}</strong>
<strong className="text-sm text-[#111]">
{task.label}
</strong>
<span className="mt-0.5 text-gray-500">{task.desc}</span>
</div>
<select
value={mappings[task.key] || ""}
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
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"
>
<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>
))}
<option value="">Use Default Provider</option>
{instances.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.providerName})
{inst.isActive ? " [Active]" : ""}
</option>
))}
</select>
</div>
))}
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Environment Variables Default</h2>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default Model</span>
<span className="text-sm">
<code className="font-mono text-sm">{config.model}</code>
</span>
</div>
<div className="flex justify-between border-b border-gray-100 py-1.5">
<span className="text-sm text-gray-500">Default API Key (.env)</span>
<span
className={
config.apiKeySet
? "text-sm text-green-600"
: "text-sm font-medium text-red-600"
}
>
{config.apiKeySet
? `✓ Set (${config.apiKeyPreview})`
: "✗ NOT SET"}
</span>
</div>
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<section className="mb-8 pb-6">
<h2 className="mb-3 text-lg">Available Scenarios</h2>
{config.availableScenarios.length === 0 ? (
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
No scenarios found in <code className="font-mono text-xs">content/demo/scenarios/</code>.
No scenarios found in{" "}
<code className="font-mono text-xs">
content/demo/scenarios/
</code>
.
</p>
) : (
<table className="w-full border-collapse text-sm">
@@ -508,7 +503,9 @@ export default function ConfigPage() {
<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">
<code className="font-mono text-xs text-blue-600">{s.path}</code>
<code className="font-mono text-xs text-blue-600">
{s.path}
</code>
</td>
</tr>
))}
@@ -516,18 +513,7 @@ export default function ConfigPage() {
</table>
)}
</section>
<section className="mb-8 border-b border-gray-200 pb-6">
<h2 className="mb-3 text-lg">Engine Packages</h2>
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
All <code className="font-mono text-xs">@omnia/*</code> workspace packages are
consumed via <code className="font-mono text-xs">transpilePackages</code> in{" "}
<code className="font-mono text-xs">next.config.ts</code>. The native{" "}
<code className="font-mono text-xs">better-sqlite3</code> module is externalized
via <code className="font-mono text-xs">serverExternalPackages</code>.
</p>
</section>
</>
</div>
)}
</div>
);

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, ModelProviderInstance, AVAILABLE_PROVIDERS, ModelProviderMeta } from "@omnia/llm";
import { ProviderManager, LLMProviderInstance, AVAILABLE_PROVIDERS, LLMProviderMeta } 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<ModelProviderInstance[]> {
export async function listProviderInstances(): Promise<LLMProviderInstance[]> {
return ProviderManager.list();
}
@@ -242,9 +242,8 @@ export async function createProviderInstance(
providerName: string,
apiKey: string,
modelName?: string,
type: "generative" | "embedding" = "generative",
): Promise<ModelProviderInstance> {
return ProviderManager.create(name, providerName, apiKey, modelName, type);
): Promise<LLMProviderInstance> {
return ProviderManager.create(name, providerName, apiKey, modelName);
}
export async function deleteProviderInstance(id: string): Promise<void> {
@@ -261,9 +260,8 @@ export async function updateProviderInstance(
providerName: string,
apiKey?: string,
modelName?: string,
type: "generative" | "embedding" = "generative",
): Promise<void> {
ProviderManager.update(id, name, providerName, apiKey, modelName, type);
ProviderManager.update(id, name, providerName, apiKey, modelName);
}
export async function getProviderMappings(): Promise<Record<string, string>> {
@@ -277,10 +275,6 @@ export async function setProviderMapping(
ProviderManager.setMapping(task, providerInstanceId);
}
export async function getAvailableProviders(): Promise<ModelProviderMeta[]> {
export async function getAvailableProviders(): Promise<LLMProviderMeta[]> {
return AVAILABLE_PROVIDERS;
}
export async function regenerateEmbeddings(newProviderInstanceId?: string): Promise<void> {
await simulationManager.regenerateAllEmbeddings(newProviderInstanceId);
}

View File

@@ -17,8 +17,10 @@ import type { LLMProviderInstance } from "@omnia/llm";
function IntentTag({
intent,
isSelf,
}: {
intent: SimSnapshot["log"][number]["intents"][number];
isSelf?: boolean;
}) {
const labels: Record<string, string> = {
monologue: "thought",
@@ -33,9 +35,19 @@ function IntentTag({
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
}
const textToDisplay = (isSelf && intent.selfDescription)
? intent.selfDescription
: intent.description;
const modifiersStr = intent.modifiers && intent.modifiers.length > 0 ? (
<span className="intent-modifiers" style={{ fontStyle: "italic", opacity: 0.8, color: "#4b5563", marginLeft: "0.25rem" }}>
({intent.modifiers.join(", ")})
</span>
) : null;
return (
<span className="intent-tag">
[{label}] &ldquo;{intent.description}&rdquo;{outcome}
[{label}] &ldquo;{textToDisplay}&rdquo;{modifiersStr}{outcome}
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
</span>
);
@@ -50,6 +62,77 @@ function PromptModal({
}) {
const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor");
const parseActorPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
const memoryHeader = "=== YOUR RECENT MEMORY ===";
const idx = userContext.indexOf(memoryHeader);
let worldStr = userContext;
let memStr = "";
if (idx !== -1) {
worldStr = userContext.substring(0, idx).trim();
memStr = userContext.substring(idx).trim();
}
const sysLen = systemPrompt.length;
const worldLen = worldStr.length;
const memLen = memStr.length;
const totalLen = sysLen + worldLen + memLen;
if (totalLen === 0) return null;
const sysPct = (sysLen / totalLen) * 100;
const worldPct = (worldLen / totalLen) * 100;
const memPct = (memLen / totalLen) * 100;
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
const memTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
return [
{ label: "System Prompt", pct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
{ label: "World Info", pct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
{ label: "Recent Memories", pct: memPct, tokens: memTokens, type: "memories", content: memStr || "(No memories yet.)" },
];
};
const parseDecoderPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
const proseHeader = "=== NARRATIVE PROSE ===";
const idx = userContext.indexOf(proseHeader);
let worldStr = userContext;
let proseStr = "";
if (idx !== -1) {
worldStr = userContext.substring(0, idx).trim();
proseStr = userContext.substring(idx).trim();
}
const sysLen = systemPrompt.length;
const worldLen = worldStr.length;
const proseLen = proseStr.length;
const totalLen = sysLen + worldLen + proseLen;
if (totalLen === 0) return null;
const sysPct = (sysLen / totalLen) * 100;
const worldPct = (worldLen / totalLen) * 100;
const prosePct = (proseLen / totalLen) * 100;
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
const proseTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
return [
{ label: "System Prompt", pct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
{ label: "Decoder Context", pct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
{ label: "Narrative Prose", pct: prosePct, tokens: proseTokens, type: "memories", content: proseStr },
];
};
const actorBreakdown = (entry.rawPrompt && entry.usage) ? parseActorPrompt(entry.rawPrompt.systemPrompt, entry.rawPrompt.userContext, entry.usage.inputTokens) : null;
const decoderBreakdown = (entry.decoderPrompt && entry.decoderUsage) ? parseDecoderPrompt(entry.decoderPrompt.systemPrompt, entry.decoderPrompt.userContext, entry.decoderUsage.inputTokens) : null;
useEffect(() => {
if (!entry.rawPrompt && entry.decoderPrompt) {
setActiveTab("decoder");
@@ -85,50 +168,124 @@ function PromptModal({
{activeTab === "actor" && entry.rawPrompt && (
<div className="tab-pane">
{entry.usage ? (
<div className="usage-stats">
<strong>Token Usage:</strong>
<span>Input: <code>{entry.usage.inputTokens}</code></span> &middot;{" "}
<span>Output: <code>{entry.usage.outputTokens}</code></span> &middot;{" "}
<span>Total: <code>{entry.usage.totalTokens}</code></span>
<div className="provider-info">
<strong>LLM Instance:</strong> <span>{entry.usage.providerInstanceName || "Default"}</span>
{entry.usage.modelName && (
<span> ({entry.usage.modelName})</span>
)}
</div>
) : (
<div className="usage-stats italic text-gray">
<div className="provider-info italic text-gray">
No LLM token usage (Player turn used fixed prose).
</div>
)}
<div className="prompt-field">
<h4>System Prompt</h4>
<pre>{entry.rawPrompt.systemPrompt}</pre>
</div>
{actorBreakdown && (
<div className="prompt-breakdown-container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: 600 }}>Input Prompt Breakdown</span>
<span>Total Input Tokens: <strong>{entry.usage?.inputTokens}</strong></span>
</div>
<div className="prompt-breakdown-bar">
{actorBreakdown.map((item, idx) => (
<div
key={idx}
className={`bar-section ${item.type}`}
style={{ width: `${item.pct}%` }}
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
/>
))}
</div>
<div className="breakdown-accordion">
{actorBreakdown.map((item, idx) => (
<details key={idx} className="breakdown-accordion-item" open={idx === 0}>
<summary className="accordion-header">
<span className={`legend-color ${item.type}`} />
<span className="header-text">
{item.label}: <strong>{item.tokens}</strong> tokens ({item.pct.toFixed(0)}%)
</span>
<span className="accordion-chevron"></span>
</summary>
<div className="accordion-content">
<pre>{item.content}</pre>
</div>
</details>
))}
</div>
</div>
)}
<div className="prompt-field">
<h4>User Context</h4>
<pre>{entry.rawPrompt.userContext}</pre>
</div>
{entry.usage && (
<div className="prompt-output-section" style={{ marginTop: "0.5rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.5rem" }}>
<span style={{ fontWeight: 600 }}>LLM Output</span>
<span>Total Output Tokens: <strong>{entry.usage.outputTokens}</strong></span>
</div>
<div className="accordion-content" style={{ border: "1px solid #e5e7eb", borderRadius: "6px" }}>
<pre>{entry.narrativeProse}</pre>
</div>
</div>
)}
</div>
)}
{activeTab === "decoder" && entry.decoderPrompt && (
<div className="tab-pane">
{entry.decoderUsage && (
<div className="usage-stats">
<strong>Token Usage:</strong>
<span>Input: <code>{entry.decoderUsage.inputTokens}</code></span> &middot;{" "}
<span>Output: <code>{entry.decoderUsage.outputTokens}</code></span> &middot;{" "}
<span>Total: <code>{entry.decoderUsage.totalTokens}</code></span>
<div className="provider-info">
<strong>LLM Instance:</strong> <span>{entry.decoderUsage.providerInstanceName || "Default"}</span>
{entry.decoderUsage.modelName && (
<span> ({entry.decoderUsage.modelName})</span>
)}
</div>
)}
<div className="prompt-field">
<h4>System Prompt</h4>
<pre>{entry.decoderPrompt.systemPrompt}</pre>
</div>
{decoderBreakdown && (
<div className="prompt-breakdown-container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: 600 }}>Input Prompt Breakdown</span>
<span>Total Input Tokens: <strong>{entry.decoderUsage?.inputTokens}</strong></span>
</div>
<div className="prompt-breakdown-bar">
{decoderBreakdown.map((item, idx) => (
<div
key={idx}
className={`bar-section ${item.type}`}
style={{ width: `${item.pct}%` }}
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
/>
))}
</div>
<div className="breakdown-accordion">
{decoderBreakdown.map((item, idx) => (
<details key={idx} className="breakdown-accordion-item" open={idx === 0}>
<summary className="accordion-header">
<span className={`legend-color ${item.type}`} />
<span className="header-text">
{item.label}: <strong>{item.tokens}</strong> tokens ({item.pct.toFixed(0)}%)
</span>
<span className="accordion-chevron"></span>
</summary>
<div className="accordion-content">
<pre>{item.content}</pre>
</div>
</details>
))}
</div>
</div>
)}
<div className="prompt-field">
<h4>User Context</h4>
<pre>{entry.decoderPrompt.userContext}</pre>
</div>
{entry.decoderUsage && (
<div className="prompt-output-section" style={{ marginTop: "0.5rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.5rem" }}>
<span style={{ fontWeight: 600 }}>LLM Output</span>
<span>Total Output Tokens: <strong>{entry.decoderUsage.outputTokens}</strong></span>
</div>
<div className="accordion-content" style={{ border: "1px solid #e5e7eb", borderRadius: "6px" }}>
<pre>{JSON.stringify(entry.intents, null, 2)}</pre>
</div>
</div>
)}
</div>
)}
</div>
@@ -137,12 +294,30 @@ function PromptModal({
);
}
function formatSimTime(isoString: string) {
try {
const d = new Date(isoString);
if (isNaN(d.getTime())) return isoString;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const min = String(d.getUTCMinutes()).padStart(2, "0");
const ss = String(d.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
} catch {
return isoString;
}
}
function LogEntryCard({
entry,
onShowPrompt,
isPlayerCard,
}: {
entry: SimSnapshot["log"][number];
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
isPlayerCard: boolean;
}) {
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
@@ -153,7 +328,7 @@ function LogEntryCard({
<strong>{entry.entityName}</strong>
<span className="log-meta">
Turn {entry.turn} &middot;{" "}
{new Date(entry.timestamp).toLocaleTimeString()}
{formatSimTime(entry.timestamp)}
</span>
</div>
{showMenu && (
@@ -169,7 +344,7 @@ function LogEntryCard({
<div className="log-prose">{entry.narrativeProse}</div>
<div className="log-intents">
{entry.intents.map((intent, i) => (
<IntentTag key={i} intent={intent} />
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
))}
</div>
</div>
@@ -185,6 +360,7 @@ export function PlayView() {
const [selectedEntryForModal, setSelectedEntryForModal] = useState<SimSnapshot["log"][number] | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const steppingRef = useRef(false);
const pauseRequestedRef = useRef(false);
const scrollToBottom = useCallback(() => {
setTimeout(
@@ -203,10 +379,14 @@ export function PlayView() {
steppingRef.current = true;
setLoading(true);
setError("");
pauseRequestedRef.current = false;
try {
let current = snapshot;
while (true) {
if (pauseRequestedRef.current) {
break;
}
const result = await stepSimulation({ simId: id });
if (!result.ok) {
setError(result.error);
@@ -276,6 +456,8 @@ export function PlayView() {
setSnapshot(res.snapshot);
if (res.snapshot.status === "running") {
await runSteps(res.snapshot.id);
} else {
setLoading(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resume session.");
@@ -307,7 +489,6 @@ export function PlayView() {
const [selectedEntity, setSelectedEntity] = useState("");
const [providerInstances, setProviderInstances] = useState<LLMProviderInstance[]>([]);
const [selectedProviderInstance, setSelectedProviderInstance] = useState("");
// Load scenarios and provider instances on mount
useEffect(() => {
@@ -324,12 +505,6 @@ export function PlayView() {
try {
const providersList = await listProviderInstances();
setProviderInstances(providersList);
const active = providersList.find(p => p.isActive);
if (active) {
setSelectedProviderInstance(active.id);
} else if (providersList.length > 0) {
setSelectedProviderInstance(providersList[0].id);
}
} catch {
// ignore
}
@@ -372,7 +547,6 @@ export function PlayView() {
const result = await startSimulation({
scenario: (form.get("scenario") as string) || undefined,
playEntity: (form.get("playEntity") as string) || undefined,
providerInstanceId: selectedProviderInstance || undefined,
});
if (!result.ok) {
@@ -385,6 +559,8 @@ export function PlayView() {
if (result.snapshot.status === "running") {
await runSteps(result.snapshot.id);
} else {
setLoading(false);
}
} catch (err) {
setError(
@@ -491,26 +667,8 @@ export function PlayView() {
))}
</select>
</div>
<div className="field">
<label htmlFor="llmInstance">LLM Key / Instance</label>
<select
id="llmInstance"
value={selectedProviderInstance}
onChange={(e) => setSelectedProviderInstance(e.target.value)}
disabled={providerInstances.length === 0}
>
{providerInstances.length === 0 ? (
<option value="">Default (from Env variable)</option>
) : (
providerInstances.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.providerName}) {p.isActive ? " [Active]" : ""}
</option>
))
)}
</select>
</div>
<button type="submit" disabled={loading}>
<button type="submit" disabled={loading || providerInstances.length === 0}>
{loading ? "Starting..." : "Start Simulation"}
</button>
</form>
@@ -534,7 +692,7 @@ export function PlayView() {
</span>
</div>
<div className="card-actions">
<button onClick={() => handleResume(s.id)} disabled={loading}>
<button onClick={() => handleResume(s.id)} disabled={loading || providerInstances.length === 0}>
Resume
</button>
<button
@@ -560,15 +718,36 @@ export function PlayView() {
<div className="sim-info-header">
<h2>{snapshot.scenarioName}</h2>
{snapshot.status !== "done" && snapshot.status !== "error" && (
<button
className="stop-btn"
onClick={() => {
setSnapshot(null);
setError("");
}}
>
Stop
</button>
<div style={{ display: "flex", gap: "0.5rem" }}>
{snapshot.status === "running" && (
loading ? (
<button
className="pause-btn"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</button>
) : (
<button
className="resume-btn"
onClick={() => runSteps(snapshot.id)}
>
Resume
</button>
)
)}
<button
className="stop-btn"
onClick={() => {
setSnapshot(null);
setError("");
}}
>
Stop
</button>
</div>
)}
</div>
<p>{snapshot.scenarioDescription}</p>
@@ -579,13 +758,17 @@ export function PlayView() {
</div>
<div className="log-container">
{snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
/>
))}
{(() => {
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="log-processing">
<span className="spinner" />
@@ -721,6 +904,18 @@ export function PlayView() {
padding: 0.25rem 0.75rem;
}
.pause-btn {
background: #d97706;
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
}
.resume-btn {
background: #059669;
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
}
.sim-info {
margin-bottom: 1rem;
}
@@ -959,18 +1154,112 @@ export function PlayView() {
flex-direction: column;
gap: 1rem;
}
.usage-stats {
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e3a8a;
padding: 0.625rem 0.875rem;
.provider-info {
background: #f9fafb;
border: 1px solid #e5e7eb;
color: #374151;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.prompt-breakdown-bar {
display: flex;
height: 24px;
width: 100%;
border-radius: 4px;
overflow: hidden;
background: #e5e7eb;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.bar-section {
height: 100%;
transition: width 0.3s ease;
}
.bar-section.system {
background: #3b82f6;
}
.bar-section.world {
background: #10b981;
}
.bar-section.memories {
background: #f59e0b;
}
.breakdown-accordion {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
details.breakdown-accordion-item {
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 0.5rem;
background: #fff;
overflow: hidden;
}
summary.accordion-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f9fafb;
cursor: pointer;
user-select: none;
font-size: 0.8125rem;
font-weight: 500;
}
summary.accordion-header::-webkit-details-marker {
display: none;
}
summary.accordion-header {
list-style: none;
}
.header-text {
flex-grow: 1;
}
.accordion-chevron {
font-size: 0.75rem;
color: #9ca3af;
transition: transform 0.2s ease;
}
details[open] .accordion-chevron {
transform: rotate(180deg);
}
.accordion-content {
padding: 0.75rem;
border-top: 1px solid #e5e7eb;
background: #fafafa;
}
.accordion-content pre {
margin: 0;
padding: 0.5rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
max-height: 250px;
overflow-y: auto;
color: #1f2937;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
}
.legend-color.system {
background: #3b82f6;
}
.legend-color.world {
background: #10b981;
}
.legend-color.memories {
background: #f59e0b;
}
.usage-stats code {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;

View File

@@ -1,6 +1,8 @@
export interface IntentInfo {
type: string;
description: string;
selfDescription?: string;
modifiers: string[];
targetIds: string[];
isValid?: boolean;
reason?: string;
@@ -22,6 +24,8 @@ export interface LogEntry {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
};
decoderPrompt?: {
systemPrompt: string;
@@ -31,6 +35,8 @@ export interface LogEntry {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
};
}

View File

@@ -17,7 +17,7 @@ for (const c of envCandidates) {
}
}
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { BufferRepository } 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, IEmbeddingProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, ModelProviderInstance } from "@omnia/llm";
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type {
@@ -89,7 +89,6 @@ interface SimSession {
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
ledgerRepo: LedgerRepository;
worldInstanceId: string;
scenarioName: string;
scenarioDescription: string;
@@ -102,7 +101,6 @@ interface SimSession {
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
@@ -121,16 +119,9 @@ class SimulationManager {
playEntityName?: string,
providerInstanceId?: string,
): Promise<SimSnapshot> {
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");
}
}
const activeInstance = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId)
: ProviderManager.getActive();
if (!activeInstance) {
return {
@@ -156,7 +147,6 @@ 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;
@@ -222,52 +212,34 @@ class SimulationManager {
}
const list = ProviderManager.list();
const active = ProviderManager.getActive("generative") || activeInstance;
const active = ProviderManager.getActive() || 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 || inst.type !== "generative") {
if (!inst) {
inst = active;
}
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
const instanceName = inst ? inst.name : undefined;
if (providerName === "google-genai") {
return new GeminiProvider(key, modelName);
return new GeminiProvider(key, modelName, instanceName);
} else if (providerName === "openrouter") {
return new OpenRouterProvider(key, modelName);
return new OpenRouterProvider(key, modelName, instanceName);
} 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 embeddingProvider = resolveEmbeddingProvider();
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
@@ -280,10 +252,9 @@ class SimulationManager {
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: worldInstanceId,
worldInstanceId,
scenarioName: scenarioJson.name,
scenarioDescription: scenarioJson.description || "",
scenarioDescription: scenarioJson.description,
turn: 1,
maxTurns: 20,
entities: entityInfos,
@@ -293,7 +264,6 @@ class SimulationManager {
validatorProvider,
decoderProvider,
timedeltaProvider,
embeddingProvider,
architect,
aliasGenerator,
log: [],
@@ -377,7 +347,6 @@ class SimulationManager {
const playerActor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
new FixedProseGenerator(prose),
);
@@ -416,6 +385,8 @@ class SimulationManager {
entry.intents.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
@@ -488,7 +459,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, session.ledgerRepo, 20);
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, 20);
const { systemPrompt, userContext } = promptBuilder.build(
worldState,
entity,
@@ -518,7 +489,6 @@ class SimulationManager {
const actor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
);
const result = await actor.act(worldState, entity);
@@ -560,6 +530,8 @@ class SimulationManager {
entry.intents.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
@@ -676,20 +648,14 @@ class SimulationManager {
}
const list = ProviderManager.list();
const active = ProviderManager.getActive("generative");
const active = ProviderManager.getActive();
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 || 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");
}
inst = active;
}
if (!inst) {
@@ -697,47 +663,21 @@ class SimulationManager {
}
if (inst.providerName === "google-genai") {
return new GeminiProvider(inst.apiKey, inst.modelName);
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name);
} else if (inst.providerName === "openrouter") {
return new OpenRouterProvider(inst.apiKey, inst.modelName);
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name);
} 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 embeddingProvider = resolveEmbeddingProvider();
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
@@ -750,7 +690,6 @@ class SimulationManager {
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: id,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
@@ -763,7 +702,6 @@ class SimulationManager {
validatorProvider,
decoderProvider,
timedeltaProvider,
embeddingProvider,
architect,
aliasGenerator,
log: state.log || [],
@@ -831,53 +769,6 @@ 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

@@ -11,12 +11,6 @@
"visibility": "PRIVATE",
"allowedEntities": []
},
{
"name": "observation_status",
"value": "Active monitoring. Audio and visual feeds online.",
"visibility": "PRIVATE",
"allowedEntities": []
},
{
"name": "ambient_sound",
"value": "A low, barely audible electrical hum.",
@@ -54,6 +48,11 @@
"visibility": "PRIVATE",
"allowedEntities": ["7c9b83b3-8cfb-4e89-8d77-626a5757d591"]
},
{
"name": "gender",
"value": "male",
"visibility": "PUBLIC"
},
{
"name": "appearance",
"value": "A tall human with short dark hair and alert eyes, standing near the center of the room.",
@@ -96,6 +95,11 @@
"visibility": "PRIVATE",
"allowedEntities": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
},
{
"name": "gender",
"value": "male",
"visibility": "PUBLIC"
},
{
"name": "appearance",
"value": "A medium-build human with long blonde hair tied back, sitting with their back pressed against the white wall.",

View File

@@ -9,8 +9,6 @@ import {
BufferEntry,
BufferRepository,
serializeSubjectiveBufferEntry,
LedgerEntry,
LedgerRepository,
} from "@omnia/memory";
/**
@@ -39,17 +37,12 @@ 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,
) {}
/**
@@ -66,29 +59,31 @@ export class ActorPromptBuilder {
private buildSystemPrompt(): string {
return `
You are an actor agent embodying a single character in a narrative simulation. You ARE this character act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
You are an actor agent embodying a single character in a narrative simulation. You ARE this character: act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
Your output is a short block of narrative prose describing what your character does, says, or thinks next. You may:
- Speak aloud → this becomes a "dialogue" intent. Other entities can hear it.
- Perform a physical or logical action → this becomes an "action" intent. It is subject to the world's physics and will be validated by the World Architect.
- Think internally / reflect / feel → this becomes a "monologue" intent. NO ONE else perceives it. It bypasses all validation and is written straight to your private memory. Use this for inner thoughts, doubts, plans, and feelings that you would not voice aloud.
- Speak aloud → Other entities can hear it if they are present nearby. (Or nobody will hear it if you are alone)
- Perform a physical action → It is subject to the world's physics and logic. Do not describe the outcome of your action.
- Think internally / reflect / feel → this is a "monologue". NO ONE else perceives it. This is what you think internally.
Guidelines:
- Always write in the first person (e.g., "I do this", "I say", "I think").
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment, the room, your surroundings, or other characters' actions, as these are managed by the simulation engine.
- Stay strictly within what your character knows. If an attribute, entity, or fact is not present in your context below, your character does not know it — do not invent it or act on it.
- Refer to other entities by the subjective names/aliases given in your context, never by raw system IDs.
- Keep your prose vivid but concise. A single response may contain more than one intent (e.g., you may think, then speak, then act) — write them in natural narrative order.
- Always write in the first person
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment or your surroundings, or other characters' actions.
- Refer to other entities by the subjective names/aliases that you refer to them as.
- Keep your prose vivid but concise. Write it in natural narrative order.
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
- Never speak or act on another entity's behalf — you only control your own character.
- Never speak or act on another entity's behalf. You only control your own character.
- Stay strictly within what your character knows. Do not invent knowledge that doesn't exist or act on it.
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying.
".
`.trim();
}
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.`,
);
@@ -98,43 +93,30 @@ 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, recentEntries, now);
const memorySection = this.buildMemorySection(
entity,
worldState.clock.get(),
);
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,
entries: BufferEntry[],
now: Date,
): string | null {
private buildMemorySection(entity: Entity, 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 `=== RECENT EVENTS ===\n(No recent events recorded.)`;
return `=== YOUR RECENT MEMORY ===\n(You have no memories yet.)`;
}
const recent = entries.slice(-this.memoryLimit);
@@ -154,110 +136,6 @@ Guidelines:
groupedLines.push(` - ${serialized}`);
}
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 = entity.aliases.get(targetId) ?? 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")}`;
return `=== YOUR RECENT MEMORY ===\n${groupedLines.join("\n")}`;
}
}

View File

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

View File

@@ -1,100 +0,0 @@
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");
// Bob should be resolved to Strider
expect(userContext).toContain("spoke to Strider");
// Check long-term memory exists
expect(userContext).toContain("=== YOUR MEMORIES ===");
// Bob should be resolved to Strider in the ledger content
expect(userContext).toContain("alice met Strider at the tavern.");
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

@@ -22,8 +22,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
type: "action",
originalText: "open the chest and read the scroll",
description: "Open the chest and read the scroll",
selfDescription: "You open the chest and read the scroll.",
actorId: "alice",
targetIds: [],
modifiers: [],
};
const result = await architect.validateIntent(world, intent);
@@ -51,8 +53,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
type: "action",
originalText: "unlock the gate and escape",
description: "Unlock the gate and escape",
selfDescription: "You unlock the gate and escape.",
actorId: "bob",
targetIds: [],
modifiers: [],
};
const result = await architect.validateIntent(world, intent);
@@ -72,8 +76,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
type: "action",
originalText: "haunt the mansion",
description: "Haunt the mansion",
selfDescription: "You haunt the mansion.",
actorId: "ghost",
targetIds: [],
modifiers: [],
};
const result = await architect.validateIntent(world, intent);
@@ -110,8 +116,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
type: "action",
originalText: "pick the lock of the wooden chest",
description: "Pick the lock of the wooden chest",
selfDescription: "You pick the lock of the wooden chest.",
actorId: "alice",
targetIds: [],
modifiers: [],
};
const result = await architect.processIntent(world, intent);
@@ -152,8 +160,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
type: "action",
originalText: "run away",
description: "Run away",
selfDescription: "You run away.",
actorId: "bob",
targetIds: [],
modifiers: [],
};
const result = await architect.processIntent(world, intent);

View File

@@ -1,6 +1,6 @@
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
import { WorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import { IntentSequence, IntentSequenceSchema } from "./intent.js";
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
export class IntentDecoder {
constructor(private llmProvider: ILLMProvider) {}
@@ -23,9 +23,15 @@ export class IntentDecoder {
const actor = worldState.getEntity(actorId);
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
const aliasContext = aliasEntries.length > 0
? aliasEntries.map(([targetId, alias]) => `- "${alias}" refers to entity ID: "${targetId}"`).join("\n")
: "(No known aliases)";
const aliasContext =
aliasEntries.length > 0
? aliasEntries
.map(
([targetId, alias]) =>
`- "${alias}" refers to entity ID: "${targetId}"`,
)
.join("\n")
: "(No known aliases)";
const systemPrompt = `
You are the Intent Decoder for a narrative simulation engine.
@@ -33,20 +39,16 @@ Your job is to take a block of narrative prose written by an actor agent and dec
For each intent you must:
1. Classify its type:
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
- "monologue": An inner thought, reflection, or internal monologue. This is purely internal — not spoken aloud, not perceivable by any other entity, and not a physical action. Use this for any prose depicting the character thinking, reflecting, feeling, or narrating to themselves internally.
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
- "monologue": An inner thought, reflection, or internal monologue/self narration.
2. Extract the original text fragment from the prose that corresponds to this intent.
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array. For "monologue" intents, targetIds must always be an empty array.
Rules:
- Preserve the chronological order of intents as they appear in the prose.
- Do NOT merge unrelated actions into a single intent.
- Dialogue and actions should be separate intents even if they happen in the same sentence.
- If the prose contains only dialogue, return a single dialogue intent.
- If the prose contains only a single action, return a single action intent.
3. Populate "description" and "selfDescription":
- "description": No subject or name — a bare third-person verb phrase only (e.g. "clears their throat", "shakes their head slowly")
- "selfDescription": The same event from the actor's own perspective, second person, complete sentence starting with "You" (e.g. "You clear your throat.", "You shake your head slowly."). This is shown directly in the actor's own memory — it must never say "the actor" or refer to them in the third person.
- In case of a dialogue, the description and self Description only stores the exact words said by the entity. (e.g. "I will do that later", "Are you serious right now?")
4. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" mapping to resolve any subjective names,or aliases used in the prose to their correct system entity IDs. If no specific target, use an empty array.
5. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
`.trim();
const userContext = `
@@ -58,7 +60,7 @@ The actor refers to other entities using these subjective names/aliases:
${aliasContext}
=== WORLD STATE ===
${serializeObjectiveWorldState(worldState)}
${serializeSimplifiedWorldState(worldState)}
=== ACTOR ===
Actor ID: ${actorId}
@@ -70,7 +72,7 @@ ${narrativeProse}
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
userContext,
schema: IntentSequenceSchema,
schema: LLMIntentSequenceSchema,
});
if (!response.success || !response.data) {
@@ -79,6 +81,42 @@ ${narrativeProse}
);
}
return response.data;
const fullIntents = response.data.intents.map((intent) => ({
...intent,
actorId,
}));
return {
intents: fullIntents,
};
}
}
function serializeSimplifiedWorldState(worldState: WorldState): string {
const lines: string[] = [];
lines.push("Locations:");
if (worldState.locations.size > 0) {
for (const loc of worldState.locations.values()) {
const parentId = (loc as { parentId?: string | null }).parentId;
const parentStr = parentId ? ` (Parent: ${parentId})` : "";
lines.push(` - Location [ID: ${loc.id}]${parentStr}`);
}
} else {
lines.push(" (No locations)");
}
lines.push("Entities:");
if (worldState.entities.size > 0) {
for (const entity of worldState.entities.values()) {
const locStr = entity.locationId
? ` (Location: ${entity.locationId})`
: "";
lines.push(` - Entity [ID: ${entity.id}]${locStr}`);
}
} else {
lines.push(" (No entities)");
}
return lines.join("\n");
}

View File

@@ -14,7 +14,7 @@ export type IntentType = z.infer<typeof IntentTypeSchema>;
/**
* A single decoded intent extracted from narrative prose.
*/
export const IntentSchema = z.object({
export const LLMIntentSchema = z.object({
/** The type of intent. */
type: IntentTypeSchema,
@@ -24,8 +24,8 @@ export const IntentSchema = z.object({
/** A concise, structured description of the intent's action or dialogue. */
description: z.string(),
/** The entity ID of the actor performing the intent. */
actorId: z.string(),
/** The same event from the actor's own perspective (second person, "You"). */
selfDescription: z.string(),
/**
* Entity IDs of the receiving parties (e.g., who is being spoken to,
@@ -33,10 +33,25 @@ export const IntentSchema = z.object({
* "monologue" intents, since they are not perceivable by anyone.
*/
targetIds: z.array(z.string()),
/**
* Additional qualities or modifiers extracted from the prose (e.g., emotions,
* questions, speed, manner of action like 'quietly', 'whispering', 'anxiously').
*/
modifiers: z.array(z.string()),
});
export const IntentSchema = LLMIntentSchema.extend({
/** The entity ID of the actor performing the intent. */
actorId: z.string(),
});
export type Intent = z.infer<typeof IntentSchema>;
export const LLMIntentSequenceSchema = z.object({
intents: z.array(LLMIntentSchema),
});
/**
* The full output of the Intent Decoder: an ordered sequence of intents
* extracted from a single narrative prose block.

View File

@@ -15,8 +15,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
type: "action",
originalText: "Alice opened the chest.",
description: "Open the wooden chest.",
actorId: "alice",
selfDescription: "You open the wooden chest.",
targetIds: [],
modifiers: [],
},
],
};
@@ -45,8 +46,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
type: "dialogue",
originalText: '"Do you have the key?" Alice asked Bob.',
description: "Alice asks Bob if he has the key.",
actorId: "alice",
selfDescription: "You ask Bob if he has the key.",
targetIds: ["bob"],
modifiers: [],
},
],
};
@@ -78,15 +80,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
type: "dialogue",
originalText: '"Cover me," Alice whispered to Bob.',
description: "Alice whispers to Bob requesting cover.",
actorId: "alice",
selfDescription: "You whisper to Bob requesting cover.",
targetIds: ["bob"],
modifiers: [],
},
{
type: "action",
originalText: "She crept towards the door and pulled the handle.",
description: "Creep towards the door and pull the handle.",
actorId: "alice",
selfDescription: "You creep towards the door and pull the handle.",
targetIds: [],
modifiers: [],
},
],
};

View File

@@ -15,6 +15,8 @@ export interface LLMResponse<T> {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
};
}
@@ -25,60 +27,53 @@ export interface LLMCallRecord {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
};
}
export interface ILLMProvider {
providerName: string;
// We use Zod to ensure the generic T matches the schema
generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>>;
lastCalls?: LLMCallRecord[];
}
export interface IEmbeddingProvider {
providerName: string;
embed(text: string): Promise<number[]>;
}
export interface ModelProviderInstance {
export interface LLMProviderInstance {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: boolean;
modelName?: string;
type: "generative" | "embedding";
}
export interface ModelProviderMeta {
export interface LLMProviderMeta {
id: string;
displayName: string;
description: string;
defaultModel: string;
defaultEmbeddingModel: string;
}
export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
export const AVAILABLE_PROVIDERS: LLMProviderMeta[] = [
{
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,18 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { ModelProviderInstance } from "./llm.js";
import type { LLMProviderInstance } from "./llm.js";
let dbPathOverride: string | null = null;
let hasBootstrapped = false;
export function setDbPathOverride(p: string | null) {
dbPathOverride = p;
}
export function resetHasBootstrapped() {
hasBootstrapped = false;
}
function getWorkspaceRoot() {
let current = process.cwd();
@@ -20,12 +31,17 @@ function getWorkspaceRoot() {
}
function getSettingsDb() {
const wsRoot = getWorkspaceRoot();
const dbDir = path.resolve(wsRoot, "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
let dbPath: string;
if (dbPathOverride) {
dbPath = dbPathOverride;
} else {
const wsRoot = getWorkspaceRoot();
const dbDir = path.resolve(wsRoot, "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
dbPath = path.join(dbDir, "settings.db");
}
const dbPath = path.join(dbDir, "settings.db");
const db = new Database(dbPath);
db.prepare(`
@@ -35,8 +51,7 @@ function getSettingsDb() {
providerName TEXT NOT NULL,
apiKey TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 0,
modelName TEXT,
type TEXT NOT NULL DEFAULT 'generative'
modelName TEXT
)
`).run();
@@ -46,17 +61,44 @@ function getSettingsDb() {
// ignore
}
// Auto-bootstrap environment variables if DB contains 0 instances
try {
db.prepare(`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`).run();
if (!hasBootstrapped) {
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 hasInserted = 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;
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInserted ? 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");
}
}
hasBootstrapped = true;
}
} catch {
// ignore
// ignore write lock issues or other DB errors during bootstrap
}
return db;
}
export class ProviderManager {
static list(): ModelProviderInstance[] {
static list(): LLMProviderInstance[] {
const db = getSettingsDb();
try {
const rows = db.prepare(`SELECT * FROM provider_instances`).all() as {
@@ -66,7 +108,6 @@ export class ProviderManager {
apiKey: string;
isActive: number;
modelName?: string;
type: string;
}[];
return rows.map((r) => ({
id: r.id,
@@ -75,34 +116,25 @@ export class ProviderManager {
apiKey: r.apiKey,
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
type: (r.type as "generative" | "embedding") || "generative",
}));
} finally {
db.close();
}
}
static create(
name: string,
providerName: string,
apiKey: string,
modelName?: string,
type: "generative" | "embedding" = "generative"
): ModelProviderInstance {
static create(name: string, providerName: string, apiKey: string, modelName?: string): LLMProviderInstance {
const db = getSettingsDb();
try {
const id = "provider-" + Date.now();
const activeCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`)
.get(type) as { count: number };
const activeCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1`).get() as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(id, name, providerName, apiKey, isActive, modelName || null, type);
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, name, providerName, apiKey, isActive, modelName || null);
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName, type };
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName };
} finally {
db.close();
}
@@ -111,13 +143,11 @@ export class ProviderManager {
static delete(id: string): void {
const db = getSettingsDb();
try {
const provider = db.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`).get(id) as { isActive: number; type: string } | undefined;
const provider = db.prepare(`SELECT isActive FROM provider_instances WHERE id = ?`).get(id) as { isActive: number } | 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 WHERE type = ? LIMIT 1`)
.get(provider.type) as { id: string } | undefined;
const next = db.prepare(`SELECT id FROM provider_instances LIMIT 1`).get() as { id: string } | undefined;
if (next) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(next.id);
}
@@ -130,96 +160,67 @@ export class ProviderManager {
static setActive(id: string): void {
const db = getSettingsDb();
try {
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);
}
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,
type: "generative" | "embedding" = "generative"
): void {
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 = ?, type = ?
SET name = ?, providerName = ?, apiKey = ?, modelName = ?
WHERE id = ?
`).run(name, providerName, apiKey, modelName || null, type, id);
`).run(name, providerName, apiKey, modelName || null, id);
} else {
db.prepare(`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?
SET name = ?, providerName = ?, modelName = ?
WHERE id = ?
`).run(name, providerName, modelName || null, type, id);
`).run(name, providerName, modelName || null, id);
}
} finally {
db.close();
}
}
static getActive(type: "generative" | "embedding" = "generative"): ModelProviderInstance | null {
static getActive(): LLMProviderInstance | null {
const db = getSettingsDb();
try {
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
// Query the DB
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1`).get() as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
} | undefined;
if (!row) {
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
if (totalCount.count === 0) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey && envKey.trim()) {
const id = "provider-default-env";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(id, "Default (Env)", "google-genai", envKey, 1, "gemini-2.5-flash", "generative");
const embedId = "provider-default-env-embed";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(embedId, "Default Embed (Env)", "google-genai", envKey, 1, "gemini-embedding-001", "embedding");
if (type === "embedding") {
return {
id: embedId,
name: "Default Embed (Env)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
};
}
return {
id,
name: "Default (Env)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-2.5-flash",
type: "generative",
};
}
// 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 {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
} | undefined;
if (firstRow) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(firstRow.id);
return {
id: firstRow.id,
name: firstRow.name,
providerName: firstRow.providerName,
apiKey: firstRow.apiKey,
isActive: true,
modelName: firstRow.modelName || undefined,
};
}
return null;
}
@@ -231,30 +232,29 @@ export class ProviderManager {
apiKey: row.apiKey,
isActive: true,
modelName: row.modelName || undefined,
type: (row.type as "generative" | "embedding") || "generative",
};
} catch {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
if (type === "embedding") {
return {
id: "provider-default-env-embed-fallback",
name: "Default Embed (Env Fallback)",
providerName: "google-genai",
apiKey: envKey,
isActive: true,
modelName: "gemini-embedding-001",
type: "embedding",
};
}
// Lock or write issue fallback: return an in-memory active key if env key exists
const googleKey = process.env.GOOGLE_API_KEY;
if (googleKey && googleKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "Default (Env Fallback)",
name: "Gemini (Env Fallback)",
providerName: "google-genai",
apiKey: envKey,
apiKey: googleKey.trim(),
isActive: true,
modelName: "gemini-2.5-flash",
type: "generative",
};
}
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (openRouterKey && openRouterKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "OpenRouter (Env Fallback)",
providerName: "openrouter",
apiKey: openRouterKey.trim(),
isActive: true,
modelName: "google/gemini-2.5-flash",
};
}
return null;

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings } from "@langchain/google-genai";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
@@ -12,33 +12,43 @@ export class GeminiProvider implements ILLMProvider {
providerName = "Gemini";
private model: ChatGoogleGenerativeAI;
private modelNameUsed: string;
private providerInstanceName?: string;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string) {
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
if (!key) {
const active = ProviderManager.getActive("generative");
if (active) {
const active = ProviderManager.getActive();
if (active && active.providerName === GeminiProvider.providerId) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
}
}
if (!key) {
key = llmConfig.GOOGLE_API_KEY;
if (!this.providerInstanceName && key) {
this.providerInstanceName = "Environment Variable";
}
}
if (!key) {
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
}
this.modelNameUsed = model || "gemini-2.5-flash";
this.model = new ChatGoogleGenerativeAI({
apiKey: key,
model: model || "gemini-2.5-flash",
model: this.modelNameUsed,
});
}
@@ -63,11 +73,13 @@ export class GeminiProvider implements ILLMProvider {
const parsed = result?.parsed;
const raw = result?.raw;
const usage = raw?.usage_metadata ? {
inputTokens: raw.usage_metadata.input_tokens || 0,
outputTokens: raw.usage_metadata.output_tokens || 0,
totalTokens: raw.usage_metadata.total_tokens || 0,
} : undefined;
const usage = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,
@@ -78,43 +90,3 @@ 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, IEmbeddingProvider } from "../llm.js";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
export class MockLLMProvider implements ILLMProvider {
static readonly providerId = "mock";
@@ -34,21 +34,3 @@ 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

@@ -12,33 +12,43 @@ export class OpenRouterProvider implements ILLMProvider {
providerName = "OpenRouter";
private model: ChatOpenRouter;
private modelNameUsed: string;
private providerInstanceName?: string;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string) {
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
if (!key) {
const active = ProviderManager.getActive("generative");
if (active) {
const active = ProviderManager.getActive();
if (active && active.providerName === OpenRouterProvider.providerId) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
}
}
if (!key) {
key = llmConfig.OPENROUTER_API_KEY;
if (!this.providerInstanceName && key) {
this.providerInstanceName = "Environment Variable";
}
}
if (!key) {
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
}
this.modelNameUsed = model || "google/gemini-2.5-flash";
this.model = new ChatOpenRouter({
apiKey: key,
model: model || "google/gemini-2.5-flash",
model: this.modelNameUsed,
});
}
@@ -63,11 +73,13 @@ export class OpenRouterProvider implements ILLMProvider {
const parsed = result?.parsed;
const raw = result?.raw;
const usage = raw?.usage_metadata ? {
inputTokens: raw.usage_metadata.input_tokens || 0,
outputTokens: raw.usage_metadata.output_tokens || 0,
totalTokens: raw.usage_metadata.total_tokens || 0,
} : undefined;
const usage = {
inputTokens: raw?.usage_metadata?.input_tokens || 0,
outputTokens: raw?.usage_metadata?.output_tokens || 0,
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
};
this.lastCalls.push({
systemPrompt: request.systemPrompt,

View File

@@ -1,6 +1,6 @@
import { describe, test, expect } from "vitest";
import { z } from "zod";
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
import { MockLLMProvider } from "@omnia/llm";
describe("MockLLMProvider Unit Tests (Tier 1)", () => {
test("returns parsed matching data for valid mock response", async () => {
@@ -61,21 +61,3 @@ 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

@@ -91,6 +91,8 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
modelName: "google/gemini-2.5-flash",
providerInstanceName: "Default",
});
expect(provider.lastCalls.length).toBe(1);
@@ -101,6 +103,8 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
modelName: "google/gemini-2.5-flash",
providerInstanceName: "Default",
},
});
});

View File

@@ -0,0 +1,91 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import { ProviderManager, setDbPathOverride, resetHasBootstrapped } from "../src/index.js";
describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
let tempDbPath: string;
let originalGoogle: string | undefined;
let originalOpenRouter: string | undefined;
beforeEach(() => {
originalGoogle = process.env.GOOGLE_API_KEY;
originalOpenRouter = process.env.OPENROUTER_API_KEY;
delete process.env.GOOGLE_API_KEY;
delete process.env.OPENROUTER_API_KEY;
resetHasBootstrapped();
// Generate a unique temp database path for this test run
tempDbPath = path.resolve(process.cwd(), `test-settings-${Date.now()}-${Math.random().toString(36).substring(2)}.db`);
setDbPathOverride(tempDbPath);
});
afterEach(() => {
setDbPathOverride(null);
if (fs.existsSync(tempDbPath)) {
try {
fs.unlinkSync(tempDbPath);
} catch {
// ignore
}
}
if (originalGoogle !== undefined) {
process.env.GOOGLE_API_KEY = originalGoogle;
} else {
delete process.env.GOOGLE_API_KEY;
}
if (originalOpenRouter !== undefined) {
process.env.OPENROUTER_API_KEY = originalOpenRouter;
} else {
delete process.env.OPENROUTER_API_KEY;
}
});
test("auto-bootstraps Gemini and OpenRouter when database is empty and environment variables are present", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
process.env.OPENROUTER_API_KEY = "mock-openrouter-key-456";
const list = ProviderManager.list();
expect(list.length).toBe(2);
const gemini = list.find((p) => p.providerName === "google-genai");
expect(gemini).toBeDefined();
expect(gemini?.name).toBe("Gemini (Env)");
expect(gemini?.apiKey).toBe("mock-google-key-123");
expect(gemini?.modelName).toBe("gemini-2.5-flash");
expect(gemini?.isActive).toBe(true); // first inserted is active
const openrouter = list.find((p) => p.providerName === "openrouter");
expect(openrouter).toBeDefined();
expect(openrouter?.name).toBe("OpenRouter (Env)");
expect(openrouter?.apiKey).toBe("mock-openrouter-key-456");
expect(openrouter?.modelName).toBe("google/gemini-2.5-flash");
expect(openrouter?.isActive).toBe(false); // second inserted is inactive
});
test("treats bootstrapped instances as normal provider instances (editable and deletable)", () => {
process.env.GOOGLE_API_KEY = "mock-google-key-123";
// Trigger bootstrap
const list = ProviderManager.list();
expect(list.length).toBe(1);
const bootstrapped = list[0];
expect(bootstrapped.name).toBe("Gemini (Env)");
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");
// Delete instance
ProviderManager.delete(bootstrapped.id);
const listAfterDelete = ProviderManager.list();
expect(listAfterDelete.length).toBe(0);
});
});

View File

@@ -25,27 +25,28 @@ export function serializeSubjectiveBufferEntry(
entry: BufferEntry,
viewer: Entity,
): string {
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
const isSelf = viewer.id === entry.intent.actorId;
const targetAliases = entry.intent.targetIds.map((tid) =>
resolveAlias(viewer, tid),
);
let details: string;
const content = entry.intent.description.trim() || entry.intent.originalText.trim();
if (entry.intent.type === "dialogue") {
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${content}"`;
} else if (entry.intent.type === "monologue") {
details = `thought: "${content}"`;
} else {
details = content;
if (entry.outcome) {
if (isSelf) {
let details = (entry.intent.selfDescription || entry.intent.description || entry.intent.originalText).trim();
if (details.length > 0) {
details = details.charAt(0).toUpperCase() + details.slice(1);
}
if (entry.intent.type === "action" && entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
return details;
}
return `${actorAlias} ${details}`;
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
const subjectStr = actorAlias.charAt(0).toUpperCase() + actorAlias.slice(1);
let details = (entry.intent.description || entry.intent.originalText).trim();
if (entry.intent.type === "action" && entry.outcome) {
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
}
return `${subjectStr} ${details}`;
}
export class BufferRepository {

View File

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

View File

@@ -1,379 +0,0 @@
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

@@ -1,228 +0,0 @@
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

@@ -32,14 +32,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
intent: {
type: "dialogue",
originalText: '"Hello there," Bob said to Charlie.',
description: "Bob greets Charlie",
description: "says, 'Hello there' to the bartender",
selfDescription: "You say, 'Hello there' to the bartender.",
actorId: "bob",
targetIds: ["charlie"],
modifiers: [],
},
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe('the hooded figure spoke to the bartender: "Bob greets Charlie"');
expect(result).toBe("The hooded figure says, 'Hello there' to the bartender");
});
test("serializes action intent with outcome details", () => {
@@ -54,9 +56,11 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
intent: {
type: "action",
originalText: "Bob tried to break the latch.",
description: "Bob attempts to break the lock latch",
description: "attempts to break the lock latch",
selfDescription: "You attempt to break the lock latch.",
actorId: "bob",
targetIds: [],
modifiers: [],
},
outcome: {
isValid: false,
@@ -65,7 +69,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe('the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
expect(result).toBe('The hooded figure attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
});
test("serializes self-reference and unfamiliar actors", () => {
@@ -80,13 +84,15 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
type: "action",
originalText: "I opened the window.",
description: "open the window",
selfDescription: "You open the window.",
actorId: "alice",
targetIds: [],
modifiers: [],
},
};
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
expect(resultSelf).toBe("you open the window");
expect(resultSelf).toBe("You open the window.");
const entryUnfamiliar: BufferEntry = {
id: "entry-unfamiliar",
@@ -96,14 +102,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
intent: {
type: "action",
originalText: "Someone knocked.",
description: "knock on the door",
description: "knocks on the door",
selfDescription: "You knock on the door.",
actorId: "stranger-1",
targetIds: [],
modifiers: [],
},
};
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
expect(resultUnfamiliar).toBe("an unfamiliar figure knock on the door");
expect(resultUnfamiliar).toBe("An unfamiliar figure knocks on the door");
});
});
@@ -123,8 +131,10 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
type: "action",
originalText: "Alice picked up a stick.",
description: "Alice gathers a stick",
selfDescription: "You gather a stick.",
actorId: "alice",
targetIds: [],
modifiers: [],
};
const entry: BufferEntry = {

View File

@@ -57,8 +57,10 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
type: "action",
originalText: "I entered the foyer.",
description: "entered the house",
selfDescription: "You entered the house.",
actorId: "investigator",
targetIds: [],
modifiers: [],
},
},
],

View File

@@ -62,22 +62,28 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
type: "monologue",
originalText: "I can't believe Bob hasn't noticed me yet, Alice thought.",
description: "Alice internally reflects that Bob has not noticed her.",
selfDescription: "You internally reflect that Bob has not noticed you.",
actorId: "alice",
targetIds: [],
modifiers: [],
},
{
type: "dialogue",
originalText: '"Hey Bob," she called out softly.',
description: "Alice softly calls out to Bob.",
selfDescription: "You softly call out to Bob.",
actorId: "alice",
targetIds: ["bob"],
modifiers: [],
},
{
type: "action",
originalText: "She reached for the ledger on the table.",
description: "Alice reaches for the ledger on the table.",
selfDescription: "You reach for the ledger on the table.",
actorId: "alice",
targetIds: [],
modifiers: [],
},
],
};

View File

@@ -33,15 +33,19 @@ describe("Omnia Integration Tests (Tier 2)", () => {
type: "dialogue",
originalText: '"Cover me," Alice whispered to Bob.',
description: "Alice whispers to Bob to cover her.",
selfDescription: "You whisper to Bob to cover you.",
actorId: "alice",
targetIds: ["bob"],
modifiers: [],
},
{
type: "action",
originalText: "She crept towards the door and pulled the handle.",
description: "Alice creeps to the door and pulls the handle.",
selfDescription: "You creep to the door and pull the handle.",
actorId: "alice",
targetIds: [],
modifiers: [],
},
],
};
@@ -108,16 +112,20 @@ describe("Omnia Integration Tests (Tier 2)", () => {
type: "action" as const,
originalText: "She tries to unlock the gate with a hairpin.",
description: "Alice attempts to pick the lock with a hairpin.",
selfDescription: "You attempt to pick the lock with a hairpin.",
actorId: "alice",
targetIds: [],
modifiers: [],
};
const intent2 = {
type: "dialogue" as const,
originalText: '"This is useless," she mutters.',
description: "Alice mutters to herself.",
selfDescription: "You mutter to yourself.",
actorId: "alice",
targetIds: [],
modifiers: [],
};
// LLM validation / time delta mock responses:

View File

@@ -73,9 +73,10 @@ If no specific provider instance is mapped to a task, the task automatically rou
To maintain backwards-compatibility and support headless runs, live evaluation suites, and automated unit tests without requiring database pre-configuration, the config manager supports **self-bootstrapping**:
1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` or `OPENROUTER_API_KEY`.
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Default (Env)`) in `settings.db`.
3. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` to keep execution fluent and error-free.
1. When any database connection is initialized via the provider manager, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` and `OPENROUTER_API_KEY`.
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Gemini (Env)`) in `settings.db`.
3. If `process.env.OPENROUTER_API_KEY` is present, it automatically creates and saves a default provider instance (`OpenRouter (Env)`) in `settings.db`.
4. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` (`Gemini (Env Fallback)` or `OpenRouter (Env Fallback)`) to keep execution fluent and error-free.
---

View File

@@ -1,115 +0,0 @@
---
title: Tier 2 Memory (Ledger)
description: Long-term episodic memory storage and retrieval
---
Tier 2 memory lives in between the memory buffer and tier 3 dossiers and arguably takes up the largest share of the context pie.
Tier 2 memory (or long-term memory) stores historical events that happened to the entity in the past. It acts as an episodic ledger.
```ts
interface LedgerEntry {
id: string;
ownerId: string; // whose subjective memory this belongs to
timestamp: string; // ISO, tied to WorldClock when the intent causing the event happened.
locationId: string | null; // where it happened
involvedEntityIds: string[]; // who else this event concerns
content: string; // third-person narrative summary — recallable
quotes: string[]; // verbatim lines, only for high-salience dialogue
importance: number; // 110, salience assigned at handoff
embedding: number[]; // for semantic search (storage representation TBD at build time)
}
```
### Storage Model
Tier 2 memory is stored in relational tables to allow efficient deterministic filtering. Embeddings are stored as raw BLOBs (containing a serialized `Float32Array`).
To avoid the build and installation friction associated with native C-extensions like `sqlite-vec` (e.g. node-gyp issues across platforms), index optimization relies on standard SQLite secondary indices. These indices allow database queries to execute in microseconds, even with hundreds of thousands of memories:
```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);
```
### Handoff (Deferred)
The process of moving memories from the Tier 1 working buffer into Tier 2 is called **Handoff**.
During handoff, an LLM chunk-summarizes raw buffer events, extracts salient quotes, and assigns an `importance` score (1-10). Routine actions score low, while life-altering events score high.
Because this summarization requires an LLM call, it utilizes the standard `LLMProviderInstance` inference provider routing architecture just like all other callers in the system. This allows the simulation to route handoff processing to a specific model.
*Note: The automated handoff pipeline is currently deferred for future implementation.*
### Retrieval Architecture
Retrieval happens in phases to manage context window limits without running expensive vector searches across an entity's entire lifetime of memories.
#### Phase 1: Deterministic Heuristic Filtering
This is the primary database-level retrieval mechanism. We use fast SQL queries to filter down to a relevant candidate pool based on immediate context:
1. **Spatial Cues**: Fetch recent memories where `location_id` equals the entity's current location.
2. **Social Cues**: Fetch recent memories involving the `involvedEntityIds` currently in the entity's perception radius.
3. **High Salience**: Always fetch memories with `importance >= 8` regardless of spatial or social context.
#### Phase 2: Semantic & Episodic Ranking
This phase runs in application memory using the candidates returned from Phase 1:
1. **Semantic Match**: Compute cosine similarity dynamically in JS/TS memory over the candidate pool (limit 100). Since Phase 1 narrows the pool down significantly, vector comparisons are highly performant in JS, eliminating the need for native vector database extensions.
2. **Scoring Combination**: Combine recency, importance, and semantic match:
$$\text{Score} = (\text{recencyWeight} \times \text{recency}) + (\text{importanceWeight} \times \text{importanceNorm}) + (\text{relevanceWeight} \times \text{relevance})$$
Where `recency` uses an exponential decay based on elapsed hours ($\text{decayRate}^{\text{hoursElapsed}}$).
3. **Associative Chain**: When a memory is selected, automatically pull in its immediate chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity (mirroring how remembering one event triggers the memory of what happened right after).
### Retrieval Triggers & Active Focus
In crowded locations (e.g. a tavern with 15 other characters), retrieving memories for all co-located entities simultaneously would cause **context explosion**. To prevent this, Omnia utilizes an **Active Focus** trigger strategy:
- **Active Focus Scanning**: The prompt builder scans the last 10 entries of the entity's recent working memory (Tier 1 Buffer). Any character that the actor has recently spoken to, thought about, or was targeted by is placed in the "Active Focus" set.
- **Dynamic Thresholding**:
- If the number of co-located entities is small ($\le 3$), long-term memory is retrieved for all of them.
- If the location is crowded ($> 3$ entities), the system **strictly** limits long-term retrieval to the top 3 characters in "Active Focus".
- This creates a natural attention loop. When a new character interacts with the actor, they immediately enter "Active Focus" in the buffer, triggering the retrieval of their long-term history on the subsequent turn.
### Integration into Prompts
Recalled entries are formatted into the prompt using chronological relative time grouping. System-level metrics like salience/importance scores are omitted to preserve immersion, and system UUIDs are mapped to subjective aliases.
To frame the prompt naturally:
1. Tier 1 working buffer entries are presented under the header `=== RECENT EVENTS ===`, referring strictly to events happening in the present narrative context.
2. Tier 2 recalled entries are presented under the header `=== YOUR MEMORIES ===`, framing them simply as the entity's memories.
```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."
```