mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 12:02:49 +05:30
Compare commits
3 Commits
feat/memor
...
ea817d8044
| Author | SHA1 | Date | |
|---|---|---|---|
| ea817d8044 | |||
| 382507e71a | |||
| 7df685365e |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ export function PlayView() {
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
<button type="submit" disabled={loading || providerInstances.length === 0}>
|
||||
{loading ? "Starting..." : "Start Simulation"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -534,7 +534,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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
let 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,13 +212,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -245,29 +235,10 @@ class SimulationManager {
|
||||
}
|
||||
};
|
||||
|
||||
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 +251,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 +263,6 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: [],
|
||||
@@ -377,7 +346,6 @@ class SimulationManager {
|
||||
const playerActor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
);
|
||||
@@ -488,7 +456,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 +486,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);
|
||||
@@ -676,20 +643,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) {
|
||||
@@ -705,39 +666,13 @@ class SimulationManager {
|
||||
}
|
||||
};
|
||||
|
||||
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 +685,6 @@ class SimulationManager {
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId: id,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
@@ -763,7 +697,6 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
log: state.log || [],
|
||||
@@ -831,53 +764,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,
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -81,14 +74,15 @@ Guidelines:
|
||||
- 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.
|
||||
- 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.
|
||||
".
|
||||
`.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 +92,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 +135,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")}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ===");
|
||||
});
|
||||
});
|
||||
@@ -30,55 +30,46 @@ export interface LLMCallRecord {
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -19,8 +19,8 @@ export class GeminiProvider implements ILLMProvider {
|
||||
let model = modelName;
|
||||
|
||||
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;
|
||||
@@ -78,43 +78,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
let model = modelName;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
91
packages/llm/tests/provider-manager.test.ts
Normal file
91
packages/llm/tests/provider-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./buffer.js";
|
||||
export * from "./ledger.js";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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; // 1–10, 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."
|
||||
```
|
||||
Reference in New Issue
Block a user