mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 12:02:49 +05:30
Compare commits
14 Commits
feat/memor
...
3906259328
| Author | SHA1 | Date | |
|---|---|---|---|
| 3906259328 | |||
| 3951a1841e | |||
| 4cc48479fb | |||
| 77be8d59a5 | |||
| 99c11cbbfe | |||
| 224553c98c | |||
| 30ee7f9e8d | |||
| c8091ed47c | |||
| 27c8bb1cb8 | |||
| bfff93e793 | |||
| 9bdc3ca04b | |||
| ea817d8044 | |||
| 382507e71a | |||
| 7df685365e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -60,3 +60,5 @@ data/
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
__local_notes/
|
||||
|
||||
@@ -122,7 +122,7 @@ Space is a graph: `world → region → location → point of interest`, connect
|
||||
### Memory Tiers
|
||||
|
||||
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Vector Archive (planned):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Vector Archive (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Dossier (planned):** Each observer's subjective beliefs about another character.
|
||||
|
||||
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
|
||||
|
||||
24
apps/gui/components.json
Normal file
24
apps/gui/components.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@retroui": "https://retroui.dev/r/radix/{name}.json",
|
||||
"@retroui-base": "https://retroui.dev/r/base/{name}.json"
|
||||
}
|
||||
}
|
||||
2
apps/gui/next-env.d.ts
vendored
2
apps/gui/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -18,18 +18,27 @@
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "^16.2.10",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,26 @@ import {
|
||||
regenerateEmbeddings,
|
||||
} from "@/app/play/actions";
|
||||
import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface ConfigStatus {
|
||||
apiKeySet: boolean;
|
||||
@@ -30,16 +50,25 @@ export default function ConfigPage() {
|
||||
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");
|
||||
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId === "new") {
|
||||
if (selectedInstanceId === null) {
|
||||
setEditName("");
|
||||
setEditProvider("google-genai");
|
||||
setEditKey("");
|
||||
setEditModel("gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditType("generative");
|
||||
setEditMaxContext(32768);
|
||||
} else if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
setEditProvider(defaultProvider);
|
||||
@@ -48,6 +77,7 @@ export default function ConfigPage() {
|
||||
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
|
||||
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
setEditMaxContext(32768);
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst) {
|
||||
@@ -58,6 +88,7 @@ export default function ConfigPage() {
|
||||
const pMeta = availableProviders.find((p) => p.id === inst.providerName);
|
||||
setEditModel(inst.modelName || (inst.type === "embedding" ? pMeta?.defaultEmbeddingModel : pMeta?.defaultModel) || "gemini-2.5-flash");
|
||||
setEditIsActive(inst.isActive);
|
||||
setEditMaxContext(inst.maxContext !== undefined && inst.maxContext !== null ? inst.maxContext : 32768);
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
@@ -65,43 +96,31 @@ export default function ConfigPage() {
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
setEditProvider(providerId);
|
||||
const pMeta = availableProviders.find((p) => p.id === providerId);
|
||||
if (pMeta) {
|
||||
setEditModel(editType === "embedding" ? pMeta.defaultEmbeddingModel : pMeta.defaultModel);
|
||||
}
|
||||
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(type === "embedding" ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "");
|
||||
};
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
try {
|
||||
const list = await listProviderInstances();
|
||||
setInstances(list);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const list = await listProviderInstances();
|
||||
setInstances(list);
|
||||
}, []);
|
||||
|
||||
const loadMappings = useCallback(async () => {
|
||||
try {
|
||||
const maps = await getProviderMappings();
|
||||
setMappings(maps);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const maps = await getProviderMappings();
|
||||
setMappings(maps);
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await getConfigStatus();
|
||||
setConfig(result);
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const status = await getConfigStatus();
|
||||
setConfig(status);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
const provs = await getAvailableProviders();
|
||||
@@ -137,13 +156,14 @@ export default function ConfigPage() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined, editType);
|
||||
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(created.id);
|
||||
}
|
||||
targetInstanceId = created.id;
|
||||
setSelectedInstanceId(created.id);
|
||||
} else {
|
||||
if (!selectedInstanceId) return;
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst && inst.type === "embedding") {
|
||||
const isMapped = mappings["embeddings"] === selectedInstanceId;
|
||||
@@ -163,7 +183,7 @@ export default function ConfigPage() {
|
||||
}
|
||||
}
|
||||
|
||||
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined, editType);
|
||||
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(selectedInstanceId);
|
||||
}
|
||||
@@ -172,7 +192,7 @@ export default function ConfigPage() {
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
|
||||
if (shouldRegenerate && targetInstanceId !== "new") {
|
||||
if (shouldRegenerate && targetInstanceId && targetInstanceId !== "new") {
|
||||
await regenerateEmbeddings(targetInstanceId);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -183,14 +203,14 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedInstanceId === "new") 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) {
|
||||
@@ -226,29 +246,31 @@ 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>
|
||||
<button
|
||||
<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"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{instances.length === 0 ? (
|
||||
@@ -266,13 +288,15 @@ 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>
|
||||
{inst.isActive && (
|
||||
<span className="rounded-full bg-green-100 px-1.5 py-[1px] text-[0.65rem] font-semibold text-green-700">
|
||||
{inst.isActive && (
|
||||
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-green-200">
|
||||
Active
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,148 +307,150 @@ 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}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formName">Friendly Name</Label>
|
||||
<Input
|
||||
id="formName"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Instance Type</Label>
|
||||
<Select value={editType} onValueChange={(v) => handleTypeChange(v as "generative" | "embedding")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="generative">Generative (Chat / Text Completion)</SelectItem>
|
||||
<SelectItem value="embedding">Embedding (Vector generation)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Provider Type</Label>
|
||||
<Select value={editProvider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editProvider && availableProviders.length > 0 && (
|
||||
<span className="mt-1 block rounded border border-2 bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
{availableProviders.find((p) => p.id === editProvider)?.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formKey">API Key</Label>
|
||||
<Input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formModel">Model Name</Label>
|
||||
<Input
|
||||
id="formModel"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editType === "generative" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="formMaxContext">Max Context Length (Tokens, 0 for infinite)</Label>
|
||||
<Input
|
||||
id="formMaxContext"
|
||||
type="number"
|
||||
value={editMaxContext}
|
||||
onChange={(e) => setEditMaxContext(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
placeholder="e.g. 32768"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
id="formActive"
|
||||
checked={editIsActive}
|
||||
onCheckedChange={(v) => setEditIsActive(v === true)}
|
||||
/>
|
||||
<Label htmlFor="formActive" className="cursor-pointer">
|
||||
Set as Active Instance
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t-2 bg-muted/50 px-6 py-4">
|
||||
<div>
|
||||
{selectedInstanceId !== "new" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
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>
|
||||
)}
|
||||
{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">
|
||||
{[
|
||||
@@ -432,20 +458,25 @@ export default function ConfigPage() {
|
||||
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules.", type: "generative" },
|
||||
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC).", type: "generative" },
|
||||
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock.", type: "generative" },
|
||||
{ key: "handoff", label: "Memory Handoff Engine", desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.", type: "generative" },
|
||||
{ key: "embeddings", label: "Text Embeddings Generator", desc: "Generates vector embeddings for long-term memory retrieval.", type: "embedding" },
|
||||
].map((task) => (
|
||||
<div
|
||||
key={task.key}
|
||||
className="flex flex-col justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
|
||||
className="flex flex-col justify-between gap-3 rounded-lg border-2 bg-card p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<strong className="text-sm text-[#111]">{task.label}</strong>
|
||||
<span className="mt-0.5 text-gray-500">{task.desc}</span>
|
||||
<strong className="text-sm text-foreground">
|
||||
{task.label}
|
||||
</strong>
|
||||
<span className="mt-0.5 text-muted-foreground">{task.desc}</span>
|
||||
</div>
|
||||
<select
|
||||
value={mappings[task.key] || ""}
|
||||
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
|
||||
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs"
|
||||
onChange={(e) =>
|
||||
handleUpdateMapping(task.key, e.target.value)
|
||||
}
|
||||
className="w-full rounded border-2 bg-input px-2 py-1.5 text-xs shadow-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<option value="">-- Use Active Key (Default) --</option>
|
||||
{instances
|
||||
@@ -461,73 +492,40 @@ export default function ConfigPage() {
|
||||
</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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
|
||||
Name
|
||||
</th>
|
||||
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
|
||||
Path
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Path</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{config.availableScenarios.map((s) => (
|
||||
<tr key={s.path}>
|
||||
<td className="border-b border-gray-100 p-2">{s.name}</td>
|
||||
<td className="border-b border-gray-100 p-2">
|
||||
<code className="font-mono text-xs text-blue-600">{s.path}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<TableRow key={s.path}>
|
||||
<TableCell>{s.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="font-mono text-xs text-blue-600">
|
||||
{s.path}
|
||||
</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,92 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
@custom-variant data-open (&[data-state="open"], &[data-state="active"]);
|
||||
@custom-variant data-closed (&[data-state="closed"], &[data-state="inactive"]);
|
||||
@custom-variant data-active (&[data-state="active"], &[data-state="open"]);
|
||||
@custom-variant data-checked (&[data-state="checked"], &[aria-checked="true"]);
|
||||
@custom-variant data-horizontal (&[data-orientation="horizontal"]);
|
||||
@custom-variant data-vertical (&[data-orientation="vertical"]);
|
||||
@custom-variant data-popup-open (&[data-state="open"]);
|
||||
|
||||
@theme inline {
|
||||
--font-head: var(--font-head);
|
||||
--font-sans: var(--font-sans);
|
||||
--radius: var(--radius);
|
||||
|
||||
--shadow-xs: 1px 1px 0 0 var(--border);
|
||||
--shadow-sm: 2px 2px 0 0 var(--border);
|
||||
--shadow: 3px 3px 0 0 var(--border);
|
||||
--shadow-md: 4px 4px 0 0 var(--border);
|
||||
--shadow-lg: 6px 6px 0 0 var(--border);
|
||||
--shadow-xl: 10px 10px 0 1px var(--border);
|
||||
--shadow-2xl: 16px 16px 0 1px var(--border);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0;
|
||||
--background: #fff7e8;
|
||||
--foreground: #000;
|
||||
--card: #fff;
|
||||
--card-foreground: #000;
|
||||
--popover: #fff;
|
||||
--popover-foreground: #000;
|
||||
--primary: #ffdc58;
|
||||
--primary-hover: #ffd12e;
|
||||
--primary-foreground: #000;
|
||||
--secondary: #000;
|
||||
--secondary-foreground: #fff;
|
||||
--muted: #efe7d6;
|
||||
--muted-foreground: #6b6355;
|
||||
--accent: #ffe7a3;
|
||||
--accent-foreground: #000;
|
||||
--destructive: #e63946;
|
||||
--destructive-foreground: #fff;
|
||||
--border: #000;
|
||||
--input: #fff;
|
||||
--ring: #000;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #1a1815;
|
||||
--foreground: #f5f0e6;
|
||||
--card: #262320;
|
||||
--card-foreground: #f5f0e6;
|
||||
--popover: #262320;
|
||||
--popover-foreground: #f5f0e6;
|
||||
--primary: #ffdc58;
|
||||
--primary-hover: #ffd12e;
|
||||
--primary-foreground: #000;
|
||||
--secondary: #3a352f;
|
||||
--secondary-foreground: #f5f0e6;
|
||||
--muted: #2e2a24;
|
||||
--muted-foreground: #b3ac9e;
|
||||
--accent: #38342b;
|
||||
--accent-foreground: #f5f0e6;
|
||||
--destructive: #ff6b6b;
|
||||
--destructive-foreground: #1a1815;
|
||||
--border: #000;
|
||||
--input: #262320;
|
||||
--ring: #ffdc58;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Archivo_Black, Space_Grotesk } from "next/font/google";
|
||||
import { NavBar } from "@/components/nav/NavBar";
|
||||
import "./globals.css";
|
||||
|
||||
const archivoBlack = Archivo_Black({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-head",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Omnia GUI",
|
||||
description: "Omnia Narrative Simulation Engine — Web Interface",
|
||||
title: "Omnia",
|
||||
description: "Omnia Narrative Simulation Engine",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-dvh bg-[#fafafa] text-[#111]">
|
||||
<body className={`${archivoBlack.variable} ${spaceGrotesk.variable} min-h-dvh bg-background text-foreground font-sans`}>
|
||||
<NavBar />
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto max-w-[800px] px-4 py-12">
|
||||
<h1 className="mb-2 text-3xl">Omnia GUI</h1>
|
||||
<p className="mb-8 text-gray-500">
|
||||
<p className="mb-8 text-muted-foreground">
|
||||
Configuration and gameplay interface for the Omnia simulation engine.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/play"
|
||||
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
|
||||
>
|
||||
<h2 className="mb-1 text-xl">Play</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Start a simulation and interact with NPCs
|
||||
</p>
|
||||
<Link href="/play" className="flex-1 no-underline">
|
||||
<Card className="transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Play</CardTitle>
|
||||
<CardDescription>
|
||||
Start a simulation and interact with NPCs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link
|
||||
href="/config"
|
||||
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
|
||||
>
|
||||
<h2 className="mb-1 text-xl">Config</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Check environment, API keys, and available scenarios
|
||||
</p>
|
||||
<Link href="/config" className="flex-1 no-underline">
|
||||
<Card className="transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Config</CardTitle>
|
||||
<CardDescription>
|
||||
Check environment, API keys, and available scenarios
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -243,8 +243,9 @@ export async function createProviderInstance(
|
||||
apiKey: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
): Promise<ModelProviderInstance> {
|
||||
return ProviderManager.create(name, providerName, apiKey, modelName, type);
|
||||
return ProviderManager.create(name, providerName, apiKey, modelName, type, maxContext);
|
||||
}
|
||||
|
||||
export async function deleteProviderInstance(id: string): Promise<void> {
|
||||
@@ -262,8 +263,9 @@ export async function updateProviderInstance(
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number,
|
||||
): Promise<void> {
|
||||
ProviderManager.update(id, name, providerName, apiKey, modelName, type);
|
||||
ProviderManager.update(id, name, providerName, apiKey, modelName, type, maxContext);
|
||||
}
|
||||
|
||||
export async function getProviderMappings(): Promise<Record<string, string>> {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
@@ -13,57 +14,22 @@ export function NavBar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<Link href="/" className="nav-brand">
|
||||
Omnia
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<nav className="flex items-center gap-4 border-b-2 px-4 py-3">
|
||||
<Button variant="link" asChild className="font-head text-base font-bold no-underline">
|
||||
<Link href="/">Omnia</Link>
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
<Button
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={pathname === link.href ? "nav-link active" : "nav-link"}
|
||||
variant={pathname === link.href ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
<Link href={link.href}>{link.label}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<style>{`
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
.nav-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #111;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
}
|
||||
.nav-link.active {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
101
apps/gui/src/components/ui/accordion.tsx
Normal file
101
apps/gui/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Smooth, premium easing for the open/close — fast out of the gate, gentle
|
||||
// settle. Shared by the panel height and the chevron so they move in lockstep.
|
||||
const EASE = "ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn(
|
||||
"overflow-hidden rounded border-2 bg-background text-foreground shadow-md transition-shadow duration-200 hover:shadow-sm data-[state=open]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header data-slot="accordion-header" className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left font-head transition-colors hover:bg-muted/50 data-[state=open]:bg-muted/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
aria-hidden
|
||||
data-slot="accordion-trigger-icon"
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-300",
|
||||
EASE
|
||||
)}
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
// Radix publishes the measured height as `--radix-accordion-content-height`
|
||||
// and toggles `data-state`. The accordion-down/up keyframes (in
|
||||
// shadcn-tailwind.css) interpolate height between that var and 0 for a real
|
||||
// slide open/close — the Base UI variant achieves the same via transitions.
|
||||
className="group/panel overflow-hidden bg-card font-body text-sm text-muted-foreground data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 pt-2 pb-4 transition-[opacity,transform] duration-300 ease-out",
|
||||
// Fade + nudge the content as the panel opens/closes, synced to the slide.
|
||||
"group-data-[state=closed]/panel:-translate-y-1 group-data-[state=closed]/panel:opacity-0",
|
||||
"[&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
49
apps/gui/src/components/ui/badge.tsx
Normal file
49
apps/gui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded border-2 px-2 py-0.5 text-xs font-head font-medium whitespace-nowrap shadow-sm transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground [a]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"bg-transparent text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"border-transparent bg-transparent shadow-none hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "border-transparent bg-transparent shadow-none text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
apps/gui/src/components/ui/button.tsx
Normal file
56
apps/gui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
apps/gui/src/components/ui/card.tsx
Normal file
103
apps/gui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded border-2 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-md [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t-2 bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
32
apps/gui/src/components/ui/checkbox.tsx
Normal file
32
apps/gui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-5 shrink-0 items-center justify-center rounded border-2 bg-input shadow-sm transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-checked:border-border data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
167
apps/gui/src/components/ui/dialog.tsx
Normal file
167
apps/gui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-foreground/20 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded border-2 bg-popover p-4 text-sm text-popover-foreground shadow-md duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t-2 bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-head text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
19
apps/gui/src/components/ui/input.tsx
Normal file
19
apps/gui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded border-2 bg-input px-3 py-2 text-sm shadow-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
apps/gui/src/components/ui/label.tsx
Normal file
24
apps/gui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-head font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
167
apps/gui/src/components/ui/navigation-menu.tsx
Normal file
167
apps/gui/src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded group-data-[viewport=false]/navigation-menu:border-2 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-md group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-accent data-active:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
190
apps/gui/src/components/ui/select.tsx
Normal file
190
apps/gui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded border-2 bg-input py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
apps/gui/src/components/ui/separator.tsx
Normal file
28
apps/gui/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
17
apps/gui/src/components/ui/spinner.tsx
Normal file
17
apps/gui/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
data-slot="spinner"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
116
apps/gui/src/components/ui/table.tsx
Normal file
116
apps/gui/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto rounded border-2 shadow-md"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t-2 bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b-2 transition-colors hover:bg-accent has-aria-expanded:bg-accent data-[state=selected]:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 bg-muted px-2 text-left align-middle font-head font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
90
apps/gui/src/components/ui/tabs.tsx
Normal file
90
apps/gui/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded p-1 text-muted-foreground group-data-horizontal/tabs:h-11 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-2 bg-card shadow-sm",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded border-2 border-transparent px-4 py-2 text-sm font-head font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 group-data-[variant=default]/tabs-list:data-active:border-border group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-primary data-active:text-primary-foreground group-data-[variant=line]/tabs-list:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-1 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-1 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
apps/gui/src/components/ui/textarea.tsx
Normal file
18
apps/gui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded border-2 bg-input px-3 py-2 text-sm shadow-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
selfDescription?: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
@@ -22,6 +24,9 @@ export interface LogEntry {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
@@ -31,6 +36,9 @@ export interface LogEntry {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const c of envCandidates) {
|
||||
}
|
||||
}
|
||||
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { BufferRepository, LedgerRepository, HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
@@ -102,6 +102,7 @@ interface SimSession {
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
@@ -131,7 +132,6 @@ class SimulationManager {
|
||||
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative");
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeInstance) {
|
||||
return {
|
||||
id: "",
|
||||
@@ -235,11 +235,13 @@ class SimulationManager {
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
const instanceName = inst ? inst.name : undefined;
|
||||
const maxContext = inst ? inst.maxContext : undefined;
|
||||
|
||||
if (providerName === "google-genai") {
|
||||
return new GeminiProvider(key, modelName);
|
||||
return new GeminiProvider(key, modelName, instanceName, maxContext);
|
||||
} else if (providerName === "openrouter") {
|
||||
return new OpenRouterProvider(key, modelName);
|
||||
return new OpenRouterProvider(key, modelName, instanceName, maxContext);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
@@ -267,6 +269,7 @@ class SimulationManager {
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
const architect = new Architect(
|
||||
@@ -293,6 +296,7 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
@@ -320,6 +324,7 @@ class SimulationManager {
|
||||
|
||||
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await this.runAliasResolution(session);
|
||||
await this.runHandoffResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
@@ -416,6 +421,8 @@ class SimulationManager {
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
@@ -560,6 +567,8 @@ class SimulationManager {
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
@@ -609,6 +618,31 @@ class SimulationManager {
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
|
||||
private async runHandoffResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const handoffEngine = new HandoffEngine(
|
||||
session.handoffProvider,
|
||||
session.embeddingProvider,
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
);
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const entity of entities) {
|
||||
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
|
||||
const maxContext = session.handoffProvider.maxContext !== undefined ? session.handoffProvider.maxContext : 32768;
|
||||
|
||||
const trigger = checkHandoffTrigger(entity, bufferEntries, worldState.clock.get(), maxContext);
|
||||
if (trigger !== "none") {
|
||||
await handoffEngine.runHandoff(entity, bufferEntries, worldState.clock.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
@@ -685,6 +719,7 @@ class SimulationManager {
|
||||
if (!inst || inst.type !== "generative") {
|
||||
inst = active;
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
@@ -697,9 +732,9 @@ class SimulationManager {
|
||||
}
|
||||
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiProvider(inst.apiKey, inst.modelName);
|
||||
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
|
||||
} else if (inst.providerName === "openrouter") {
|
||||
return new OpenRouterProvider(inst.apiKey, inst.modelName);
|
||||
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
@@ -737,6 +772,7 @@ class SimulationManager {
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
const architect = new Architect(
|
||||
@@ -763,6 +799,7 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
|
||||
6
apps/gui/src/lib/utils.ts
Normal file
6
apps/gui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -11,12 +11,6 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": []
|
||||
},
|
||||
{
|
||||
"name": "observation_status",
|
||||
"value": "Active monitoring. Audio and visual feeds online.",
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": []
|
||||
},
|
||||
{
|
||||
"name": "ambient_sound",
|
||||
"value": "A low, barely audible electrical hum.",
|
||||
@@ -54,6 +48,11 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": ["7c9b83b3-8cfb-4e89-8d77-626a5757d591"]
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"value": "male",
|
||||
"visibility": "PUBLIC"
|
||||
},
|
||||
{
|
||||
"name": "appearance",
|
||||
"value": "A tall human with short dark hair and alert eyes, standing near the center of the room.",
|
||||
@@ -96,6 +95,11 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"value": "male",
|
||||
"visibility": "PUBLIC"
|
||||
},
|
||||
{
|
||||
"name": "appearance",
|
||||
"value": "A medium-build human with long blonde hair tied back, sitting with their back pressed against the white wall.",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.9.4",
|
||||
"shadcn": "^4.13.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vitest": "^4.1.9",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
WorldState,
|
||||
naturalizeTime,
|
||||
serializeSubjectiveWorldState,
|
||||
resolveAlias,
|
||||
} from "@omnia/core";
|
||||
import {
|
||||
BufferEntry,
|
||||
@@ -66,21 +67,23 @@ export class ActorPromptBuilder {
|
||||
|
||||
private buildSystemPrompt(): string {
|
||||
return `
|
||||
You are an actor agent embodying a single character in a narrative simulation. You ARE this character — act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
|
||||
You are an actor agent embodying a single character in a narrative simulation. You ARE this character: act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
|
||||
|
||||
Your output is a short block of narrative prose describing what your character does, says, or thinks next. You may:
|
||||
- Speak aloud → this becomes a "dialogue" intent. Other entities can hear it.
|
||||
- Perform a physical or logical action → this becomes an "action" intent. It is subject to the world's physics and will be validated by the World Architect.
|
||||
- Think internally / reflect / feel → this becomes a "monologue" intent. NO ONE else perceives it. It bypasses all validation and is written straight to your private memory. Use this for inner thoughts, doubts, plans, and feelings that you would not voice aloud.
|
||||
- Speak aloud → Other entities can hear it if they are present nearby. (Or nobody will hear it if you are alone)
|
||||
- Perform a physical action → It is subject to the world's physics and logic. Do not describe the outcome of your action.
|
||||
- Think internally / reflect / feel → this is a "monologue". NO ONE else perceives it. This is what you think internally.
|
||||
|
||||
Guidelines:
|
||||
- Always write in the first person (e.g., "I do this", "I say", "I think").
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment, the room, your surroundings, or other characters' actions, as these are managed by the simulation engine.
|
||||
- Stay strictly within what your character knows. If an attribute, entity, or fact is not present in your context below, your character does not know it — do not invent it or act on it.
|
||||
- Refer to other entities by the subjective names/aliases given in your context, never by raw system IDs.
|
||||
- Keep your prose vivid but concise. A single response may contain more than one intent (e.g., you may think, then speak, then act) — write them in natural narrative order.
|
||||
- Always write in the first person
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment or your surroundings, or other characters' actions.
|
||||
- Refer to other entities by the subjective names/aliases that you refer to them as.
|
||||
- Keep your prose vivid but concise. Write it in natural narrative order.
|
||||
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
|
||||
- Never speak or act on another entity's behalf — you only control your own character.
|
||||
- Never speak or act on another entity's behalf. You only control your own character.
|
||||
- Stay strictly within what your character knows. Do not invent knowledge that doesn't exist or act on it.
|
||||
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying.
|
||||
".
|
||||
`.trim();
|
||||
}
|
||||
|
||||
@@ -237,7 +240,7 @@ Guidelines:
|
||||
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;
|
||||
const alias = resolveAlias(entity, targetId);
|
||||
content = content.replace(new RegExp(targetId, "g"), alias);
|
||||
}
|
||||
if (entry.locationId) {
|
||||
|
||||
@@ -76,8 +76,6 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
// 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 ===");
|
||||
|
||||
@@ -22,8 +22,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "open the chest and read the scroll",
|
||||
description: "Open the chest and read the scroll",
|
||||
selfDescription: "You open the chest and read the scroll.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -51,8 +53,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "unlock the gate and escape",
|
||||
description: "Unlock the gate and escape",
|
||||
selfDescription: "You unlock the gate and escape.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -72,8 +76,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "haunt the mansion",
|
||||
description: "Haunt the mansion",
|
||||
selfDescription: "You haunt the mansion.",
|
||||
actorId: "ghost",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -110,8 +116,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
type: "action",
|
||||
originalText: "pick the lock of the wooden chest",
|
||||
description: "Pick the lock of the wooden chest",
|
||||
selfDescription: "You pick the lock of the wooden chest.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.processIntent(world, intent);
|
||||
@@ -152,8 +160,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
type: "action",
|
||||
originalText: "run away",
|
||||
description: "Run away",
|
||||
selfDescription: "You run away.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.processIntent(world, intent);
|
||||
|
||||
6
packages/core/src/alias.ts
Normal file
6
packages/core/src/alias.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Entity } from "./entity.js";
|
||||
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/**
|
||||
* Monorepo Hygiene Note:
|
||||
* If a pure function only touches types owned by core (e.g., Entity, WorldState, Attribute),
|
||||
* it belongs here in the core package (e.g., in a dedicated file like alias.ts), even if
|
||||
* a higher-level package is currently its only consumer.
|
||||
*/
|
||||
export * from "./attribute.js";
|
||||
export * from "./entity.js";
|
||||
export * from "./world.js";
|
||||
export * from "./clock.js";
|
||||
export * from "./repository.js";
|
||||
export * from "./alias.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AttributableObject, Attribute, serializeAttributes } from "./attribute.js";
|
||||
import { Entity } from "./entity.js";
|
||||
import { WorldClock } from "./clock.js";
|
||||
import { resolveAlias } from "./alias.js";
|
||||
|
||||
export class WorldState extends AttributableObject {
|
||||
/**
|
||||
@@ -118,19 +119,6 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves how a viewer subjectively refers to a target entity.
|
||||
* - Self → "you"
|
||||
* - Known (in the viewer's alias map) → the subjective alias
|
||||
* - Unknown → "an unfamiliar figure"
|
||||
*
|
||||
* Mirrors the implementation in @omnia/memory's resolveAlias, inlined here
|
||||
* to avoid a circular dependency (memory depends on core).
|
||||
*/
|
||||
function resolveAliasViewer(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a single attribute the way a viewer perceives it — name and
|
||||
@@ -164,7 +152,7 @@ export function serializeSubjectiveWorldState(
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const viewerAlias = resolveAliasViewer(viewer, viewerId);
|
||||
const viewerAlias = resolveAlias(viewer, viewerId);
|
||||
|
||||
// --- World attributes (only those the viewer can see) ---
|
||||
const worldVisible = worldState.getVisibleAttributesFor(viewerId);
|
||||
@@ -208,7 +196,7 @@ export function serializeSubjectiveWorldState(
|
||||
if (coLocated.length > 0) {
|
||||
lines.push(" Entities present with you:");
|
||||
for (const e of coLocated) {
|
||||
const alias = resolveAliasViewer(viewer, e.id);
|
||||
const alias = resolveAlias(viewer, e.id);
|
||||
lines.push(` - ${alias}:`);
|
||||
const eVisible = e.getVisibleAttributesFor(viewerId);
|
||||
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
|
||||
@@ -220,7 +208,7 @@ export function serializeSubjectiveWorldState(
|
||||
if (elsewhere.length > 0) {
|
||||
lines.push(" Other presences you are aware of (elsewhere):");
|
||||
for (const e of elsewhere) {
|
||||
const alias = resolveAliasViewer(viewer, e.id);
|
||||
const alias = resolveAlias(viewer, e.id);
|
||||
lines.push(` - ${alias} [elsewhere]`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { IntentSequence, IntentSequenceSchema } from "./intent.js";
|
||||
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
|
||||
export class IntentDecoder {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
@@ -23,9 +23,15 @@ export class IntentDecoder {
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext = aliasEntries.length > 0
|
||||
? aliasEntries.map(([targetId, alias]) => `- "${alias}" refers to entity ID: "${targetId}"`).join("\n")
|
||||
: "(No known aliases)";
|
||||
const aliasContext =
|
||||
aliasEntries.length > 0
|
||||
? aliasEntries
|
||||
.map(
|
||||
([targetId, alias]) =>
|
||||
`- "${alias}" refers to entity ID: "${targetId}"`,
|
||||
)
|
||||
.join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
@@ -33,20 +39,16 @@ Your job is to take a block of narrative prose written by an actor agent and dec
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue. This is purely internal — not spoken aloud, not perceivable by any other entity, and not a physical action. Use this for any prose depicting the character thinking, reflecting, feeling, or narrating to themselves internally.
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue/self narration.
|
||||
2. Extract the original text fragment from the prose that corresponds to this intent.
|
||||
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
|
||||
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
|
||||
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array. For "monologue" intents, targetIds must always be an empty array.
|
||||
|
||||
Rules:
|
||||
- Preserve the chronological order of intents as they appear in the prose.
|
||||
- Do NOT merge unrelated actions into a single intent.
|
||||
- Dialogue and actions should be separate intents even if they happen in the same sentence.
|
||||
- If the prose contains only dialogue, return a single dialogue intent.
|
||||
- If the prose contains only a single action, return a single action intent.
|
||||
3. Populate "description" and "selfDescription":
|
||||
- "description": No subject or name — a bare third-person verb phrase only (e.g. "clears their throat", "shakes their head slowly")
|
||||
- "selfDescription": The same event from the actor's own perspective, second person, complete sentence starting with "You" (e.g. "You clear your throat.", "You shake your head slowly."). This is shown directly in the actor's own memory — it must never say "the actor" or refer to them in the third person.
|
||||
- In case of a dialogue, the description and self Description only stores the exact words said by the entity. (e.g. "I will do that later", "Are you serious right now?")
|
||||
4. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" mapping to resolve any subjective names,or aliases used in the prose to their correct system entity IDs. If no specific target, use an empty array.
|
||||
5. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
@@ -58,7 +60,7 @@ The actor refers to other entities using these subjective names/aliases:
|
||||
${aliasContext}
|
||||
|
||||
=== WORLD STATE ===
|
||||
${serializeObjectiveWorldState(worldState)}
|
||||
${serializeSimplifiedWorldState(worldState)}
|
||||
|
||||
=== ACTOR ===
|
||||
Actor ID: ${actorId}
|
||||
@@ -70,7 +72,7 @@ ${narrativeProse}
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
schema: IntentSequenceSchema,
|
||||
schema: LLMIntentSequenceSchema,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -79,6 +81,42 @@ ${narrativeProse}
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
const fullIntents = response.data.intents.map((intent) => ({
|
||||
...intent,
|
||||
actorId,
|
||||
}));
|
||||
|
||||
return {
|
||||
intents: fullIntents,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSimplifiedWorldState(worldState: WorldState): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("Locations:");
|
||||
if (worldState.locations.size > 0) {
|
||||
for (const loc of worldState.locations.values()) {
|
||||
const parentId = (loc as { parentId?: string | null }).parentId;
|
||||
const parentStr = parentId ? ` (Parent: ${parentId})` : "";
|
||||
lines.push(` - Location [ID: ${loc.id}]${parentStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No locations)");
|
||||
}
|
||||
|
||||
lines.push("Entities:");
|
||||
if (worldState.entities.size > 0) {
|
||||
for (const entity of worldState.entities.values()) {
|
||||
const locStr = entity.locationId
|
||||
? ` (Location: ${entity.locationId})`
|
||||
: "";
|
||||
lines.push(` - Entity [ID: ${entity.id}]${locStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No entities)");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type IntentType = z.infer<typeof IntentTypeSchema>;
|
||||
/**
|
||||
* A single decoded intent extracted from narrative prose.
|
||||
*/
|
||||
export const IntentSchema = z.object({
|
||||
export const LLMIntentSchema = z.object({
|
||||
/** The type of intent. */
|
||||
type: IntentTypeSchema,
|
||||
|
||||
@@ -24,8 +24,8 @@ export const IntentSchema = z.object({
|
||||
/** A concise, structured description of the intent's action or dialogue. */
|
||||
description: z.string(),
|
||||
|
||||
/** The entity ID of the actor performing the intent. */
|
||||
actorId: z.string(),
|
||||
/** The same event from the actor's own perspective (second person, "You"). */
|
||||
selfDescription: z.string(),
|
||||
|
||||
/**
|
||||
* Entity IDs of the receiving parties (e.g., who is being spoken to,
|
||||
@@ -33,10 +33,25 @@ export const IntentSchema = z.object({
|
||||
* "monologue" intents, since they are not perceivable by anyone.
|
||||
*/
|
||||
targetIds: z.array(z.string()),
|
||||
|
||||
/**
|
||||
* Additional qualities or modifiers extracted from the prose (e.g., emotions,
|
||||
* questions, speed, manner of action like 'quietly', 'whispering', 'anxiously').
|
||||
*/
|
||||
modifiers: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const IntentSchema = LLMIntentSchema.extend({
|
||||
/** The entity ID of the actor performing the intent. */
|
||||
actorId: z.string(),
|
||||
});
|
||||
|
||||
export type Intent = z.infer<typeof IntentSchema>;
|
||||
|
||||
export const LLMIntentSequenceSchema = z.object({
|
||||
intents: z.array(LLMIntentSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* The full output of the Intent Decoder: an ordered sequence of intents
|
||||
* extracted from a single narrative prose block.
|
||||
|
||||
@@ -15,8 +15,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "Alice opened the chest.",
|
||||
description: "Open the wooden chest.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You open the wooden chest.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -45,8 +46,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Do you have the key?" Alice asked Bob.',
|
||||
description: "Alice asks Bob if he has the key.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You ask Bob if he has the key.",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -78,15 +80,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob requesting cover.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You whisper to Bob requesting cover.",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Creep towards the door and pull the handle.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You creep towards the door and pull the handle.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface LLMResponse<T> {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,11 +28,15 @@ export interface LLMCallRecord {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
maxContext?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ILLMProvider {
|
||||
providerName: string;
|
||||
maxContext?: number;
|
||||
generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>>;
|
||||
@@ -49,6 +56,7 @@ export interface ModelProviderInstance {
|
||||
isActive: boolean;
|
||||
modelName?: string;
|
||||
type: "generative" | "embedding";
|
||||
maxContext?: number;
|
||||
}
|
||||
|
||||
export interface ModelProviderMeta {
|
||||
|
||||
@@ -3,6 +3,17 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import type { ModelProviderInstance } 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();
|
||||
while (current !== "/" && current !== path.parse(current).root) {
|
||||
@@ -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(`
|
||||
@@ -51,6 +67,51 @@ function getSettingsDb() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`).run();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Auto-bootstrap environment variables if DB contains 0 instances
|
||||
try {
|
||||
if (!hasBootstrapped) {
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
let hasInsertedGenerative = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
|
||||
hasInsertedGenerative = true;
|
||||
|
||||
const embedId = "provider-default-google-embed";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
|
||||
}
|
||||
}
|
||||
hasBootstrapped = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore write lock issues or other DB errors during bootstrap
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -67,6 +128,7 @@ export class ProviderManager {
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -76,6 +138,7 @@ export class ProviderManager {
|
||||
isActive: r.isActive === 1,
|
||||
modelName: r.modelName || undefined,
|
||||
type: (r.type as "generative" | "embedding") || "generative",
|
||||
maxContext: r.maxContext !== undefined && r.maxContext !== null ? r.maxContext : (r.type === "embedding" ? 0 : 32768),
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -87,7 +150,8 @@ export class ProviderManager {
|
||||
providerName: string,
|
||||
apiKey: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative"
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number
|
||||
): ModelProviderInstance {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
@@ -97,12 +161,14 @@ export class ProviderManager {
|
||||
.get(type) as { count: number };
|
||||
const isActive = activeCount.count === 0 ? 1 : 0;
|
||||
|
||||
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, providerName, apiKey, isActive, modelName || null, type);
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, providerName, apiKey, isActive, modelName || null, type, actualMaxContext);
|
||||
|
||||
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName, type };
|
||||
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName, type, maxContext: actualMaxContext };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -146,22 +212,24 @@ export class ProviderManager {
|
||||
providerName: string,
|
||||
apiKey?: string,
|
||||
modelName?: string,
|
||||
type: "generative" | "embedding" = "generative"
|
||||
type: "generative" | "embedding" = "generative",
|
||||
maxContext?: number
|
||||
): void {
|
||||
const db = getSettingsDb();
|
||||
try {
|
||||
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
|
||||
if (apiKey && apiKey.trim()) {
|
||||
db.prepare(`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?
|
||||
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, apiKey, modelName || null, type, id);
|
||||
`).run(name, providerName, apiKey, modelName || null, type, actualMaxContext, id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE provider_instances
|
||||
SET name = ?, providerName = ?, modelName = ?, type = ?
|
||||
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
|
||||
WHERE id = ?
|
||||
`).run(name, providerName, modelName || null, type, id);
|
||||
`).run(name, providerName, modelName || null, type, actualMaxContext, id);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -179,48 +247,89 @@ export class ProviderManager {
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
|
||||
if (!row) {
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey && envKey.trim()) {
|
||||
const id = "provider-default-env";
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
let hasInsertedGenerative = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Default (Env)", "google-genai", envKey, 1, "gemini-2.5-flash", "generative");
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
|
||||
hasInsertedGenerative = true;
|
||||
|
||||
const embedId = "provider-default-env-embed";
|
||||
const embedId = "provider-default-google-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");
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
|
||||
}
|
||||
|
||||
if (type === "embedding") {
|
||||
return {
|
||||
id: embedId,
|
||||
name: "Default Embed (Env)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
isActive: true,
|
||||
modelName: "gemini-embedding-001",
|
||||
type: "embedding",
|
||||
};
|
||||
}
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInsertedGenerative ? 0 : 1;
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
|
||||
}
|
||||
|
||||
const retryRow = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
|
||||
if (retryRow) {
|
||||
return {
|
||||
id,
|
||||
name: "Default (Env)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
id: retryRow.id,
|
||||
name: retryRow.name,
|
||||
providerName: retryRow.providerName,
|
||||
apiKey: retryRow.apiKey,
|
||||
isActive: true,
|
||||
modelName: "gemini-2.5-flash",
|
||||
type: "generative",
|
||||
modelName: retryRow.modelName || undefined,
|
||||
type: retryRow.type as "generative" | "embedding",
|
||||
maxContext: retryRow.maxContext !== undefined && retryRow.maxContext !== null ? retryRow.maxContext : (retryRow.type === "embedding" ? 0 : 32768),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no active row but some rows exist, return the first one as active, or update it
|
||||
const firstRow = db.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`).get(type) as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
type: string;
|
||||
maxContext?: number;
|
||||
} | undefined;
|
||||
if (firstRow) {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(firstRow.id);
|
||||
return {
|
||||
id: firstRow.id,
|
||||
name: firstRow.name,
|
||||
providerName: firstRow.providerName,
|
||||
apiKey: firstRow.apiKey,
|
||||
isActive: true,
|
||||
modelName: firstRow.modelName || undefined,
|
||||
type: firstRow.type as "generative" | "embedding",
|
||||
maxContext: firstRow.maxContext !== undefined && firstRow.maxContext !== null ? firstRow.maxContext : (firstRow.type === "embedding" ? 0 : 32768),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -232,29 +341,50 @@ export class ProviderManager {
|
||||
isActive: true,
|
||||
modelName: row.modelName || undefined,
|
||||
type: (row.type as "generative" | "embedding") || "generative",
|
||||
maxContext: row.maxContext !== undefined && row.maxContext !== null ? row.maxContext : (row.type === "embedding" ? 0 : 32768),
|
||||
};
|
||||
} catch {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
if (type === "embedding") {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
if (type === "embedding") {
|
||||
if (googleKey && googleKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-embed-fallback",
|
||||
name: "Default Embed (Env Fallback)",
|
||||
name: "Gemini Embed (Env Fallback)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
apiKey: googleKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "gemini-embedding-001",
|
||||
type: "embedding",
|
||||
maxContext: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// generative fallback
|
||||
if (googleKey && googleKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
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",
|
||||
maxContext: 32768,
|
||||
};
|
||||
}
|
||||
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",
|
||||
type: "generative",
|
||||
maxContext: 32768,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -12,33 +12,48 @@ export class GeminiProvider implements ILLMProvider {
|
||||
|
||||
providerName = "Gemini";
|
||||
private model: ChatGoogleGenerativeAI;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string) {
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
this.maxContextUsed = maxContext;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive("generative");
|
||||
if (active) {
|
||||
if (active && active.providerName === GeminiProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
if (this.maxContextUsed === undefined) {
|
||||
this.maxContextUsed = active.maxContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.GOOGLE_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "gemini-2.5-flash";
|
||||
this.model = new ChatGoogleGenerativeAI({
|
||||
apiKey: key,
|
||||
model: model || "gemini-2.5-flash",
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,11 +78,14 @@ export class GeminiProvider implements ILLMProvider {
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = raw?.usage_metadata ? {
|
||||
inputTokens: raw.usage_metadata.input_tokens || 0,
|
||||
outputTokens: raw.usage_metadata.output_tokens || 0,
|
||||
totalTokens: raw.usage_metadata.total_tokens || 0,
|
||||
} : undefined;
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
|
||||
@@ -12,33 +12,48 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
|
||||
providerName = "OpenRouter";
|
||||
private model: ChatOpenRouter;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
private maxContextUsed?: number;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string) {
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
this.maxContextUsed = maxContext;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive("generative");
|
||||
if (active) {
|
||||
if (active && active.providerName === OpenRouterProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
if (this.maxContextUsed === undefined) {
|
||||
this.maxContextUsed = active.maxContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.OPENROUTER_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "google/gemini-2.5-flash";
|
||||
this.model = new ChatOpenRouter({
|
||||
apiKey: key,
|
||||
model: model || "google/gemini-2.5-flash",
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,11 +78,14 @@ export class OpenRouterProvider implements ILLMProvider {
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = raw?.usage_metadata ? {
|
||||
inputTokens: raw.usage_metadata.input_tokens || 0,
|
||||
outputTokens: raw.usage_metadata.output_tokens || 0,
|
||||
totalTokens: raw.usage_metadata.total_tokens || 0,
|
||||
} : undefined;
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
|
||||
@@ -91,6 +91,9 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
providerInstanceName: "Default",
|
||||
maxContext: 32768,
|
||||
});
|
||||
|
||||
expect(provider.lastCalls.length).toBe(1);
|
||||
@@ -101,6 +104,9 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
providerInstanceName: "Default",
|
||||
maxContext: 32768,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
95
packages/llm/tests/provider-manager.test.ts
Normal file
95
packages/llm/tests/provider-manager.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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(3);
|
||||
|
||||
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(2);
|
||||
const bootstrapped = list.find((p) => p.name === "Gemini (Env)");
|
||||
expect(bootstrapped).toBeDefined();
|
||||
if (!bootstrapped) return;
|
||||
expect(bootstrapped.isActive).toBe(true);
|
||||
|
||||
// Edit name and key
|
||||
ProviderManager.update(bootstrapped.id, "My Gemini Key", "google-genai", "new-secret-key", "gemini-2.5-pro");
|
||||
|
||||
const listAfterUpdate = ProviderManager.list();
|
||||
expect(listAfterUpdate.length).toBe(2);
|
||||
const updated = listAfterUpdate.find((p) => p.id === bootstrapped.id);
|
||||
expect(updated).toBeDefined();
|
||||
if (!updated) return;
|
||||
expect(updated.name).toBe("My Gemini Key");
|
||||
expect(updated.apiKey).toBe("new-secret-key");
|
||||
expect(updated.modelName).toBe("gemini-2.5-pro");
|
||||
|
||||
// Delete instance
|
||||
ProviderManager.delete(bootstrapped.id);
|
||||
const listAfterDelete = ProviderManager.list();
|
||||
expect(listAfterDelete.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { Entity, resolveAlias } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
|
||||
export interface BufferEntry {
|
||||
@@ -14,38 +14,37 @@ export interface BufferEntry {
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
export { resolveAlias } from "@omnia/core";
|
||||
|
||||
export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity,
|
||||
): string {
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
const isSelf = viewer.id === entry.intent.actorId;
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map((tid) =>
|
||||
resolveAlias(viewer, tid),
|
||||
);
|
||||
|
||||
let details: string;
|
||||
const content = entry.intent.description.trim() || entry.intent.originalText.trim();
|
||||
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${content}"`;
|
||||
} else if (entry.intent.type === "monologue") {
|
||||
details = `thought: "${content}"`;
|
||||
} else {
|
||||
details = content;
|
||||
if (entry.outcome) {
|
||||
if (isSelf) {
|
||||
let details = (entry.intent.selfDescription || entry.intent.description || entry.intent.originalText).trim();
|
||||
if (details.length > 0) {
|
||||
details = details.charAt(0).toUpperCase() + details.slice(1);
|
||||
}
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
return `${actorAlias} ${details}`;
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
const subjectStr = actorAlias.charAt(0).toUpperCase() + actorAlias.slice(1);
|
||||
|
||||
let details = (entry.intent.description || entry.intent.originalText).trim();
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
|
||||
return `${subjectStr} ${details}`;
|
||||
}
|
||||
|
||||
export class BufferRepository {
|
||||
@@ -64,23 +63,31 @@ export class BufferRepository {
|
||||
location_id TEXT,
|
||||
intent_json TEXT NOT NULL,
|
||||
outcome_json TEXT,
|
||||
pinned INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
try {
|
||||
this.db.exec(`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
save(entry: BufferEntry): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
owner_id = excluded.owner_id,
|
||||
timestamp = excluded.timestamp,
|
||||
location_id = excluded.location_id,
|
||||
intent_json = excluded.intent_json,
|
||||
outcome_json = excluded.outcome_json
|
||||
outcome_json = excluded.outcome_json,
|
||||
pinned = excluded.pinned
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
@@ -90,6 +97,7 @@ export class BufferRepository {
|
||||
entry.locationId,
|
||||
JSON.stringify(entry.intent),
|
||||
entry.outcome ? JSON.stringify(entry.outcome) : null,
|
||||
entry.pinned ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +105,7 @@ export class BufferRepository {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
|
||||
FROM buffer_entries WHERE id = ?
|
||||
`,
|
||||
)
|
||||
@@ -109,6 +117,7 @@ export class BufferRepository {
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
pinned?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -121,6 +130,7 @@ export class BufferRepository {
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
pinned: row.pinned === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +138,7 @@ export class BufferRepository {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
|
||||
FROM buffer_entries WHERE owner_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`,
|
||||
@@ -140,6 +150,7 @@ export class BufferRepository {
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
pinned?: number;
|
||||
}[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -149,6 +160,7 @@ export class BufferRepository {
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
pinned: row.pinned === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
300
packages/memory/src/handoff.ts
Normal file
300
packages/memory/src/handoff.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { z } from "zod";
|
||||
import { Entity, naturalizeTime } from "@omnia/core";
|
||||
import { BufferEntry, serializeSubjectiveBufferEntry, BufferRepository } from "./buffer.js";
|
||||
import { LedgerEntry, LedgerRepository } from "./ledger.js";
|
||||
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
|
||||
export const HandoffChunkSchema = z.object({
|
||||
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
|
||||
content: z.string(), // third-person summary -> LedgerEntry.content
|
||||
quotes: z.array(z.string()), // verbatim, high-salience lines only
|
||||
importance: z.number().int().min(1).max(10),
|
||||
involvedEntityIds: z.array(z.string()),
|
||||
retainInBuffer: z.boolean(), // "pin"
|
||||
});
|
||||
|
||||
export const HandoffResultSchema = z.object({
|
||||
chunks: z.array(HandoffChunkSchema),
|
||||
});
|
||||
|
||||
export type HandoffResult = z.infer<typeof HandoffResultSchema>;
|
||||
export type HandoffTrigger = "none" | "voluntary" | "involuntary";
|
||||
|
||||
/**
|
||||
* Serializes the hypothetical memory section for size checking.
|
||||
*/
|
||||
export function getMemorySectionLength(
|
||||
entity: Entity,
|
||||
entries: BufferEntry[],
|
||||
now: Date,
|
||||
): number {
|
||||
if (entries.length === 0) {
|
||||
return `=== RECENT EVENTS ===\n(No recent events recorded.)`.length;
|
||||
}
|
||||
|
||||
const groupedLines: string[] = [];
|
||||
let currentGroup: string | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
if (when !== currentGroup) {
|
||||
currentGroup = when;
|
||||
const header = when.charAt(0).toUpperCase() + when.slice(1);
|
||||
groupedLines.push(header);
|
||||
}
|
||||
|
||||
groupedLines.push(` - ${serialized}`);
|
||||
}
|
||||
|
||||
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`.length;
|
||||
}
|
||||
|
||||
function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
|
||||
if (bufferEntries.length === 0) return false;
|
||||
|
||||
// Find the location of the most recent buffer entries
|
||||
const lastEntry = bufferEntries[bufferEntries.length - 1];
|
||||
if (lastEntry.locationId && entity.locationId && lastEntry.locationId !== entity.locationId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if there are entries from different locations in the buffer
|
||||
const locations = new Set(bufferEntries.map(e => e.locationId).filter(loc => loc !== null));
|
||||
if (locations.size > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
|
||||
const N = 5; // N consecutive idle turns
|
||||
if (bufferEntries.length < N) return false;
|
||||
|
||||
// Check the last N entries
|
||||
const lastN = bufferEntries.slice(-N);
|
||||
return lastN.every(e => e.intent.type === "monologue");
|
||||
}
|
||||
|
||||
function checkAttributeTrigger(entity: Entity): boolean {
|
||||
const consciousness = entity.attributes.get("consciousness");
|
||||
if (consciousness && consciousness.getValue().toLowerCase() === "unconscious") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = entity.attributes.get("status");
|
||||
if (status && ["unconscious", "asleep", "dead", "inactive"].includes(status.getValue().toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks deterministically whether handoff should run for the given entity.
|
||||
*/
|
||||
export function checkHandoffTrigger(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
maxContext: number = 32768,
|
||||
): HandoffTrigger {
|
||||
if (bufferEntries.length === 0) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
// Involuntary triggers first (hard)
|
||||
if (maxContext > 0) {
|
||||
const memoryLength = getMemorySectionLength(entity, bufferEntries, now);
|
||||
const charCeiling = maxContext * 4 * 0.60;
|
||||
if (memoryLength > charCeiling) {
|
||||
return "involuntary";
|
||||
}
|
||||
}
|
||||
|
||||
// Event velocity
|
||||
if (bufferEntries.length > 20) {
|
||||
return "involuntary";
|
||||
}
|
||||
|
||||
// Voluntary triggers (soft)
|
||||
if (checkSceneExit(entity, bufferEntries)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
if (checkIdleDecay(bufferEntries)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
if (checkAttributeTrigger(entity)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the buffer into candidate pool (older) and watermark tail (untouched).
|
||||
*/
|
||||
export function splitBufferForHandoff(
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
K: number = 8,
|
||||
): { candidates: BufferEntry[]; watermark: BufferEntry[] } {
|
||||
const sorted = [...bufferEntries].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
const freshBuckets = new Set([
|
||||
"just now",
|
||||
"moments ago",
|
||||
"a few minutes ago",
|
||||
"several minutes ago",
|
||||
]);
|
||||
|
||||
let watermarkStartIndex = sorted.length;
|
||||
|
||||
// 1. Mark last K entries as watermark
|
||||
if (sorted.length > K) {
|
||||
watermarkStartIndex = sorted.length - K;
|
||||
} else {
|
||||
watermarkStartIndex = 0;
|
||||
}
|
||||
|
||||
// 2. Expand watermark to include any fresh entries before it
|
||||
for (let i = watermarkStartIndex - 1; i >= 0; i--) {
|
||||
const bucket = naturalizeTime(now, new Date(sorted[i].timestamp));
|
||||
if (freshBuckets.has(bucket)) {
|
||||
watermarkStartIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
candidates: sorted.slice(0, watermarkStartIndex),
|
||||
watermark: sorted.slice(watermarkStartIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HandoffEngine processes memory handoffs using LLM summarization and DB transactions.
|
||||
*/
|
||||
export class HandoffEngine {
|
||||
constructor(
|
||||
private llmProvider: ILLMProvider,
|
||||
private embedProvider: IEmbeddingProvider,
|
||||
private bufferRepo: BufferRepository,
|
||||
private ledgerRepo: LedgerRepository,
|
||||
) {}
|
||||
|
||||
async runHandoff(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const { candidates } = splitBufferForHandoff(bufferEntries, now);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatesList = candidates.map((entry) => {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
|
||||
}).join("\n---\n");
|
||||
|
||||
const systemPrompt = `
|
||||
You are the memory Handoff Engine. Your task is to process a list of recent working memory buffer entries for an entity and select which memories to promote to the long-term Ledger, and which to forget or summarize.
|
||||
|
||||
Instructions:
|
||||
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. a full back-and-forth conversation or a single physical action and its outcome). Combine them into a single summary chunk.
|
||||
2. **Write in the third-person** for the "content" of each chunk (e.g. "John asked Mary for the key, and Mary reluctantly handed it over").
|
||||
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
|
||||
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
|
||||
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
|
||||
6. **Retain in Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the working memory buffer for immediate context. Otherwise, set it to false so it is safely pruned from the buffer.
|
||||
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
|
||||
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
Subject Entity ID: ${entity.id}
|
||||
Current Time: ${now.toISOString()}
|
||||
|
||||
Working Memory Candidates for Handoff:
|
||||
${candidatesList}
|
||||
`.trim();
|
||||
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
schema: HandoffResultSchema,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
const db = (this.bufferRepo as any).db;
|
||||
|
||||
const ledgerEntries: LedgerEntry[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
let embedding: number[] = [];
|
||||
try {
|
||||
embedding = await this.embedProvider.embed(chunk.content);
|
||||
} catch (err) {
|
||||
console.error("Failed to generate embedding for handoff chunk:", err);
|
||||
return false;
|
||||
}
|
||||
|
||||
ledgerEntries.push({
|
||||
id: "ledger-" + Math.random().toString(36).substr(2, 9) + "-" + Date.now(),
|
||||
ownerId: entity.id,
|
||||
timestamp: now.toISOString(),
|
||||
locationId: entity.locationId,
|
||||
involvedEntityIds: chunk.involvedEntityIds,
|
||||
content: chunk.content,
|
||||
quotes: chunk.quotes,
|
||||
importance: chunk.importance,
|
||||
embedding,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// Save promoted ledger entries
|
||||
for (const entry of ledgerEntries) {
|
||||
this.ledgerRepo.save(entry);
|
||||
}
|
||||
|
||||
// Keep track of pinned source IDs
|
||||
const pinnedSourceIds = new Set<string>();
|
||||
for (const chunk of result.chunks) {
|
||||
if (chunk.retainInBuffer) {
|
||||
for (const id of chunk.sourceEntryIds) {
|
||||
pinnedSourceIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete or pin entries
|
||||
for (const candidate of candidates) {
|
||||
if (pinnedSourceIds.has(candidate.id)) {
|
||||
const updated = { ...candidate, pinned: true };
|
||||
this.bufferRepo.save(updated);
|
||||
} else {
|
||||
this.bufferRepo.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Transaction failed during handoff execution:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./buffer.js";
|
||||
export * from "./ledger.js";
|
||||
export * from "./handoff.js";
|
||||
|
||||
167
packages/memory/tests/handoff.test.ts
Normal file
167
packages/memory/tests/handoff.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
checkHandoffTrigger,
|
||||
splitBufferForHandoff,
|
||||
HandoffEngine,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
const now = new Date("2026-07-07T12:00:00.000Z");
|
||||
|
||||
test("splitBufferForHandoff correctly splits based on watermark and fresh buckets", () => {
|
||||
const entries: BufferEntry[] = [];
|
||||
|
||||
// Add 12 older entries (older than 30 minutes)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const minutesAgo = 60 - i;
|
||||
const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000).toISOString();
|
||||
entries.push({
|
||||
id: `entry-old-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Old event ${i}`,
|
||||
description: `does old thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add 4 fresh entries (moments ago / just now)
|
||||
const freshTimes = [
|
||||
new Date(now.getTime() - 10 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 30 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 90 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 180 * 1000).toISOString(),
|
||||
];
|
||||
|
||||
freshTimes.forEach((timestamp, idx) => {
|
||||
entries.push({
|
||||
id: `entry-fresh-${idx}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Fresh event ${idx}`,
|
||||
description: `does fresh thing ${idx}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { candidates, watermark } = splitBufferForHandoff(entries, now, 8);
|
||||
|
||||
expect(watermark.length).toBeGreaterThanOrEqual(8);
|
||||
expect(candidates.length).toBe(8);
|
||||
expect(candidates[0].id).toBe("entry-old-0");
|
||||
});
|
||||
|
||||
test("checkHandoffTrigger detects scene change and idle decay", () => {
|
||||
const entity = new Entity("alice");
|
||||
entity.locationId = "room-2";
|
||||
|
||||
// Scenario 1: Empty buffer -> no trigger
|
||||
expect(checkHandoffTrigger(entity, [], now)).toBe("none");
|
||||
|
||||
// Scenario 2: Scene exit
|
||||
const entryAtRoom1: BufferEntry = {
|
||||
id: "e-1",
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-1",
|
||||
intent: { type: "dialogue", originalText: "hello", description: "says hello", actorId: "alice", targetIds: [] },
|
||||
};
|
||||
expect(checkHandoffTrigger(entity, [entryAtRoom1], now)).toBe("voluntary");
|
||||
|
||||
// Scenario 3: Idle decay (5 consecutive monologues)
|
||||
const monologues: BufferEntry[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `m-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-2",
|
||||
intent: { type: "monologue", originalText: "think", description: "thinks", actorId: "alice", targetIds: [] },
|
||||
}));
|
||||
expect(checkHandoffTrigger(entity, monologues, now)).toBe("voluntary");
|
||||
});
|
||||
|
||||
test("HandoffEngine promotes candidates to Ledger and prunes buffer transactionally", async () => {
|
||||
const db = new Database(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO objects (id, type, name) VALUES ('alice', 'character', 'Alice');
|
||||
`);
|
||||
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
|
||||
const entity = new Entity("alice");
|
||||
entity.locationId = "room-1";
|
||||
|
||||
const entries: BufferEntry[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const timestamp = new Date(now.getTime() - (50 - i) * 60 * 1000).toISOString();
|
||||
const entry: BufferEntry = {
|
||||
id: `entry-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: i % 2 === 0 ? "dialogue" : "action",
|
||||
originalText: `Event ${i}`,
|
||||
description: `does thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
};
|
||||
bufferRepo.save(entry);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
const mockHandoffResult = {
|
||||
chunks: [
|
||||
{
|
||||
sourceEntryIds: ["entry-0"],
|
||||
content: "Alice initiated dialogue and performed various tasks.",
|
||||
quotes: ["Event 0"],
|
||||
importance: 5,
|
||||
involvedEntityIds: ["bob"],
|
||||
retainInBuffer: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const llmProvider = new MockLLMProvider([mockHandoffResult]);
|
||||
const embedProvider = new MockEmbeddingProvider();
|
||||
const engine = new HandoffEngine(llmProvider, embedProvider, bufferRepo, ledgerRepo);
|
||||
|
||||
const success = await engine.runHandoff(entity, entries, now);
|
||||
expect(success).toBe(true);
|
||||
|
||||
const ledgerRows = db.prepare("SELECT * FROM ledger_entries WHERE owner_id = ?").all("alice") as any[];
|
||||
expect(ledgerRows.length).toBe(1);
|
||||
expect(ledgerRows[0].content).toBe("Alice initiated dialogue and performed various tasks.");
|
||||
expect(JSON.parse(ledgerRows[0].quotes_json)).toEqual(["Event 0"]);
|
||||
expect(ledgerRows[0].importance).toBe(5);
|
||||
|
||||
const remainingBuffer = bufferRepo.listForOwner("alice");
|
||||
expect(remainingBuffer.length).toBe(8);
|
||||
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-0");
|
||||
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-1");
|
||||
expect(remainingBuffer[0].id).toBe("entry-2");
|
||||
});
|
||||
});
|
||||
@@ -32,14 +32,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: '"Hello there," Bob said to Charlie.',
|
||||
description: "Bob greets Charlie",
|
||||
description: "says, 'Hello there' to the bartender",
|
||||
selfDescription: "You say, 'Hello there' to the bartender.",
|
||||
actorId: "bob",
|
||||
targetIds: ["charlie"],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('the hooded figure spoke to the bartender: "Bob greets Charlie"');
|
||||
expect(result).toBe("The hooded figure says, 'Hello there' to the bartender");
|
||||
});
|
||||
|
||||
test("serializes action intent with outcome details", () => {
|
||||
@@ -54,9 +56,11 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Bob tried to break the latch.",
|
||||
description: "Bob attempts to break the lock latch",
|
||||
description: "attempts to break the lock latch",
|
||||
selfDescription: "You attempt to break the lock latch.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
outcome: {
|
||||
isValid: false,
|
||||
@@ -65,7 +69,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
expect(result).toBe('The hooded figure attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
});
|
||||
|
||||
test("serializes self-reference and unfamiliar actors", () => {
|
||||
@@ -80,13 +84,15 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "I opened the window.",
|
||||
description: "open the window",
|
||||
selfDescription: "You open the window.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("you open the window");
|
||||
expect(resultSelf).toBe("You open the window.");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
@@ -96,14 +102,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Someone knocked.",
|
||||
description: "knock on the door",
|
||||
description: "knocks on the door",
|
||||
selfDescription: "You knock on the door.",
|
||||
actorId: "stranger-1",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
|
||||
expect(resultUnfamiliar).toBe("an unfamiliar figure knock on the door");
|
||||
expect(resultUnfamiliar).toBe("An unfamiliar figure knocks on the door");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,8 +131,10 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "Alice picked up a stick.",
|
||||
description: "Alice gathers a stick",
|
||||
selfDescription: "You gather a stick.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const entry: BufferEntry = {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" }
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ export class ScenarioLoader {
|
||||
world.addEntity(entity);
|
||||
this.coreRepo.saveEntity(entity, world.id);
|
||||
|
||||
// Seed initial memory buffer history
|
||||
if (entData.initialMemories) {
|
||||
for (const mem of entData.initialMemories) {
|
||||
this.bufferRepo.save({
|
||||
@@ -114,7 +113,11 @@ export class ScenarioLoader {
|
||||
ownerId: entData.id,
|
||||
timestamp: mem.timestamp,
|
||||
locationId: mem.locationId,
|
||||
intent: mem.intent,
|
||||
intent: {
|
||||
...mem.intent,
|
||||
selfDescription: mem.intent.selfDescription ?? "",
|
||||
modifiers: mem.intent.modifiers ?? [],
|
||||
},
|
||||
outcome: mem.outcome,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ export const ScenarioMemoryEntrySchema = z.object({
|
||||
type: z.enum(["dialogue", "action", "monologue"]),
|
||||
originalText: z.string(),
|
||||
description: z.string(),
|
||||
selfDescription: z.string().optional(),
|
||||
actorId: z.string(),
|
||||
targetIds: z.array(z.string()),
|
||||
modifiers: z.array(z.string()).optional(),
|
||||
}),
|
||||
outcome: z.object({
|
||||
isValid: z.boolean(),
|
||||
|
||||
@@ -57,8 +57,10 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "I entered the foyer.",
|
||||
description: "entered the house",
|
||||
selfDescription: "You entered the house.",
|
||||
actorId: "investigator",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
4084
pnpm-lock.yaml
generated
4084
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -62,22 +62,28 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
|
||||
type: "monologue",
|
||||
originalText: "I can't believe Bob hasn't noticed me yet, Alice thought.",
|
||||
description: "Alice internally reflects that Bob has not noticed her.",
|
||||
selfDescription: "You internally reflect that Bob has not noticed you.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Hey Bob," she called out softly.',
|
||||
description: "Alice softly calls out to Bob.",
|
||||
selfDescription: "You softly call out to Bob.",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She reached for the ledger on the table.",
|
||||
description: "Alice reaches for the ledger on the table.",
|
||||
selfDescription: "You reach for the ledger on the table.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -33,15 +33,19 @@ describe("Omnia Integration Tests (Tier 2)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob to cover her.",
|
||||
selfDescription: "You whisper to Bob to cover you.",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Alice creeps to the door and pulls the handle.",
|
||||
selfDescription: "You creep to the door and pull the handle.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -108,16 +112,20 @@ describe("Omnia Integration Tests (Tier 2)", () => {
|
||||
type: "action" as const,
|
||||
originalText: "She tries to unlock the gate with a hairpin.",
|
||||
description: "Alice attempts to pick the lock with a hairpin.",
|
||||
selfDescription: "You attempt to pick the lock with a hairpin.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const intent2 = {
|
||||
type: "dialogue" as const,
|
||||
originalText: '"This is useless," she mutters.',
|
||||
description: "Alice mutters to herself.",
|
||||
selfDescription: "You mutter to yourself.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
// LLM validation / time delta mock responses:
|
||||
|
||||
128
web/docs/src/content/docs/architecture/handoff.md
Normal file
128
web/docs/src/content/docs/architecture/handoff.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Memory Handoff Pipeline
|
||||
description: The Tier 1 (Working Buffer) to Tier 2 (Long-Term Ledger) memory promotion architecture
|
||||
sidebar:
|
||||
order: 6
|
||||
---
|
||||
|
||||
:::tip[Status: Fully Implemented]
|
||||
The handoff pipeline is fully integrated into the simulation step loop. The `HandoffEngine` and `checkHandoffTrigger` components process working memory entries, promoting them to the persistent episodic Ledger, while safely pruning the subjective working memory buffer.
|
||||
:::
|
||||
|
||||
The **Handoff** pipeline manages the promotion of subjective experiences from the short-term working memory (Tier 1 Buffer) to long-term episodic memory (Tier 2 Ledger). This process regulates context window utilization by deciding **when** promotion occurs, **how much** of the buffer is processed, and **what** details are preserved or pruned.
|
||||
|
||||
To maintain performance and prevent context bloat, the pipeline separates the process into three decoupled stages:
|
||||
|
||||
1. **Trigger Detection**: A deterministic, low-cost evaluation of entity and buffer state to determine if a promotion check is required.
|
||||
2. **Watermark Partitioning**: A policy-driven boundary that separates recent memories (the watermark tail) from older promotion candidates.
|
||||
3. **Structured Selection**: An LLM-driven synthesis step that summarizes, rates, and prunes candidate memories.
|
||||
|
||||
---
|
||||
|
||||
## 1. Trigger Detection
|
||||
|
||||
Trigger detection runs deterministically at the start of each entity's turn, without LLM overhead. The system categorizes triggers into voluntary (soft) and involuntary (hard) conditions.
|
||||
|
||||
```ts
|
||||
type HandoffTrigger = "none" | "voluntary" | "involuntary";
|
||||
|
||||
function checkHandoffTrigger(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
maxContext?: number
|
||||
): HandoffTrigger;
|
||||
```
|
||||
|
||||
### Voluntary Triggers (Soft)
|
||||
Voluntary triggers identify natural narrative boundaries where the entity is inactive or has transitioned contexts:
|
||||
* **Scene Exit**: The entity's `locationId` changes relative to the location of the entries currently in the buffer, or the buffer contains entries spanning multiple different locations.
|
||||
* **Idle Decay**: The entity has produced no external interactions (only monologue entries or no entries) for $N$ (default: 5) consecutive turns.
|
||||
* **Attribute-Driven State**: A character attribute (e.g., `status: asleep` or `consciousness: unconscious`) signals low external activity.
|
||||
|
||||
### Involuntary Triggers (Hard)
|
||||
Involuntary triggers enforce context constraints before building the next prompt to prevent token limit overflows:
|
||||
* **Buffer Ceiling**: The serialized length of the working memory section (as generated by `getMemorySectionLength`) exceeds a configured fraction (default: 60%) of the provider's context window.
|
||||
* **Event Velocity**: The number of buffer entries exceeds a safe threshold (default: 20), indicating an event-heavy scene generating history faster than voluntary boundaries occur.
|
||||
|
||||
If the buffer is empty, trigger detection exits immediately with `"none"`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Watermark Partitioning
|
||||
|
||||
To preserve short-term narrative continuity and avoid immediate memory decay, recent events are protected from handoff. The pipeline divides the active buffer into a protected **Watermark Tail** and a **Candidate Pool**.
|
||||
|
||||
The watermark boundary is computed as:
|
||||
|
||||
$$\text{Watermark} = \max(K \text{ entries}, \text{entries bucketed as fresh by } \textit{naturalizeTime})$$
|
||||
|
||||
* **Hard Floor ($K = 8$)**: The last 8 entries in the buffer are always preserved.
|
||||
* **Temporal Freshness**: Any entries that `naturalizeTime` classifies within the immediate temporal horizon (`"just now"`, `"moments ago"`, `"a few minutes ago"`, or `"several minutes ago"`) are also protected.
|
||||
|
||||
Entries older than the watermark boundary constitute the **Candidate Pool** and are eligible for handoff processing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Structured Selection
|
||||
|
||||
Handoff promotion is executed via a single, structured LLM call per run. The engine maps candidate entries to Zod schemas to ensure type-safe integration.
|
||||
|
||||
```ts
|
||||
const HandoffChunkSchema = z.object({
|
||||
sourceEntryIds: z.array(z.string()), // buffer entries consumed by this chunk
|
||||
content: z.string(), // third-person summary -> LedgerEntry.content
|
||||
quotes: z.array(z.string()), // verbatim, high-salience dialogue lines
|
||||
importance: z.number().int().min(1).max(10),
|
||||
involvedEntityIds: z.array(z.string()),
|
||||
retainInBuffer: z.boolean(), // keeps entries in buffer despite promotion
|
||||
});
|
||||
|
||||
const HandoffResultSchema = z.object({
|
||||
chunks: z.array(HandoffChunkSchema),
|
||||
});
|
||||
```
|
||||
|
||||
### LLM Processing Rules
|
||||
The prompt instructs the model to apply the following cognitive operations:
|
||||
1. **Clustering**: Group related consecutive candidate entries into high-level narrative beats (e.g., consolidating an entire dialogue exchange into one beat).
|
||||
2. **Third-Person Synthesis**: Write the `content` summary from a third-person narrative perspective.
|
||||
3. **Dialogue Salience**: Extract verbatim quotes of high emotional or narrative relevance.
|
||||
4. **Information Pruning (Forgetting)**: Any candidate entry ID omitted from all chunk `sourceEntryIds` is permanently deleted from the buffer and never saved to the ledger. This mirrors natural sensory attenuation.
|
||||
|
||||
### Unresolved Thread Retention (Pinning)
|
||||
When a chunk represents an unresolved high-stakes situation (e.g., an active conflict or standing threat), the LLM sets `retainInBuffer: true`.
|
||||
* The summary is committed to the long-term Ledger as normal.
|
||||
* The source buffer entries are exempted from pruning, remaining in the working memory buffer with `pinned: true` until a future handoff pass determines the thread is resolved.
|
||||
|
||||
---
|
||||
|
||||
## 4. Transactional Execution
|
||||
|
||||
To prevent permanent memory loss (e.g., buffer entries being deleted without ledger entries being committed), the handoff execution pipeline operates under a strict **fail-closed** design.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[checkHandoffTrigger] -->|none| Z[No-op]
|
||||
A -->|voluntary or involuntary| B[Partition Buffer]
|
||||
B --> C{Candidates Available?}
|
||||
C -->|No| Z
|
||||
C -->|Yes| D[Inference: LLM Selection & Embedding Generation]
|
||||
D -->|Failure / Invalid Output| E[Abort - Fail-Closed]
|
||||
D -->|Success| F[[SQLite Transaction]]
|
||||
F --> G[(Write Ledger Entries)]
|
||||
F --> H[(Delete Pruned Buffer Entries)]
|
||||
F --> I[(Update Pinned Buffer Entries)]
|
||||
```
|
||||
|
||||
If LLM inference, Zod schema validation, or vector embedding generation fails, the process aborts immediately. No database changes are written, and the trigger re-evaluates on the next turn.
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration
|
||||
|
||||
The handoff pipeline is exposed via `HandoffEngine` and is wired directly into the simulation loop:
|
||||
|
||||
* **Turn Hook**: `SimulationManager.step()` invokes trigger evaluation and handoff resolution for all active entities at the start of the turn, before entities act.
|
||||
* **Task Routing**: Handoff tasks are routed using the `"handoff"` routing key, enabling independent LLM provider selection and max context configuration.
|
||||
* **Buffer Garbage Collection**: By combining memory promotion with automatic pruning, the handoff engine bounds buffer memory usage over long simulation runs, eliminating the need for a separate garbage collection mechanism.
|
||||
@@ -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,32 +1,38 @@
|
||||
---
|
||||
title: Tier 2 Memory (Ledger)
|
||||
description: Long-term episodic memory storage and retrieval
|
||||
title: Tier 2 Memory (Long-Term Ledger)
|
||||
description: Persistent episodic memory storage, semantic indexing, and retrieval mechanics
|
||||
---
|
||||
|
||||
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 (the Ledger)** represents an entity's long-term episodic memory. It archives historical summaries of past events, providing a persistent record of character experiences that can be retrieved and injected into LLM prompts as needed.
|
||||
|
||||
Tier 2 memory (or long-term memory) stores historical events that happened to the entity in the past. It acts as an episodic ledger.
|
||||
---
|
||||
|
||||
## 1. Data Model
|
||||
|
||||
A long-term memory is represented by a `LedgerEntry`. It includes metadata for deterministic database-level filtering, structured narrative content, and vector embeddings for semantic similarity scoring.
|
||||
|
||||
```ts
|
||||
interface LedgerEntry {
|
||||
id: string;
|
||||
ownerId: string; // 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
|
||||
ownerId: string; // The entity to whom this subjective memory belongs
|
||||
timestamp: string; // ISO timestamp matching the WorldClock at event time
|
||||
locationId: string | null; // Location where the event transpired
|
||||
involvedEntityIds: string[]; // Other entity IDs present during the event
|
||||
|
||||
content: string; // third-person narrative summary — 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)
|
||||
content: string; // Third-person narrative summary of the event
|
||||
quotes: string[]; // Verbatim dialogue lines of high narrative salience
|
||||
importance: number; // Salience score from 1 (trivial) to 10 (life-altering)
|
||||
embedding: number[]; // 768-dimensional vector embedding of the content
|
||||
}
|
||||
```
|
||||
|
||||
### 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`).
|
||||
## 2. Storage Model
|
||||
|
||||
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:
|
||||
To support fast, low-latency queries across large historical datasets, Tier 2 memory is stored in standard SQLite tables. Vector embeddings are stored in raw binary format as `Float32Array` BLOBs.
|
||||
|
||||
Standard secondary indices optimize query execution time to microseconds, eliminating the compilation and cross-platform installation overhead of native vector database extensions (such as `sqlite-vec` or `node-gyp` binaries).
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ledger_entries (
|
||||
@@ -54,59 +60,78 @@ 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.
|
||||
## 3. The Handoff Pipeline
|
||||
|
||||
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.
|
||||
Working memory (Tier 1 Buffer) entries are promoted to the Ledger through the automated [Handoff Pipeline](./handoff).
|
||||
|
||||
*Note: The automated handoff pipeline is currently deferred for future implementation.*
|
||||
During handoff:
|
||||
1. Candidate entries are clustered into narrative beats.
|
||||
2. Ambient stage business and redundant details are pruned.
|
||||
3. Chunks are synthesized into third-person summaries and assigned importance scores.
|
||||
4. Text embeddings are generated for the summary.
|
||||
5. The processed memories are committed to the SQLite store, and the short-term buffer is pruned.
|
||||
|
||||
### Retrieval Architecture
|
||||
---
|
||||
|
||||
Retrieval happens in phases to manage context window limits without running expensive vector searches across an entity's entire lifetime of memories.
|
||||
## 4. Retrieval Architecture
|
||||
|
||||
#### Phase 1: Deterministic Heuristic Filtering
|
||||
To prevent context window overflow and contain inference costs, memory retrieval is split into a fast database query phase followed by an in-memory ranking phase.
|
||||
|
||||
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:
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Retrieval] --> B[Phase 1: SQL Filter]
|
||||
B -->|Filter by Location, Co-located Entities, and High Salience| C[Candidate Pool]
|
||||
C --> D[Phase 2: In-Memory Ranker]
|
||||
D -->|1. Cosine Similarity| E[Relevance Scores]
|
||||
D -->|2. Exponential Recency Decay| F[Recency Scores]
|
||||
E & F --> G[Compute Combined Score]
|
||||
G --> H[Chronological Associative Chaining]
|
||||
H --> I[Format Prompt Section]
|
||||
```
|
||||
|
||||
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 1: Deterministic Heuristic Filtering
|
||||
The primary selection uses indexes to retrieve a candidate pool (capped at 100 entries) from the database:
|
||||
1. **Spatial Cues**: Fetch entries matching the character's current `locationId`.
|
||||
2. **Social Cues**: Fetch entries where `involvedEntityIds` intersects with entities currently inside the character's perception radius.
|
||||
3. **High Salience**: Always retrieve high-salience entries where `importance >= 8`.
|
||||
|
||||
#### Phase 2: Semantic & Episodic Ranking
|
||||
### Phase 2: Semantic & Episodic Ranking
|
||||
Once the candidate pool is loaded, the ranking engine evaluates entries in application memory:
|
||||
1. **Semantic Similarity**: Cosine similarity is computed directly in JS/TS memory between the current prompt context and the candidate embeddings.
|
||||
2. **Multi-Factor Scoring**: Candidates are ranked using a weighted linear combination:
|
||||
$$\text{Score} = (\alpha \times \text{recency}) + (\beta \times \text{importance}) + (\gamma \times \text{relevance})$$
|
||||
* **Recency** is modeled via exponential decay based on elapsed simulation hours: $\text{decayRate}^{\text{hoursElapsed}}$.
|
||||
* **Importance** is the normalized salience score (1-10) assigned during handoff.
|
||||
* **Relevance** is the cosine similarity score.
|
||||
3. **Chronological Associative Chaining**: When a memory is selected, the system automatically pulls in its adjacent chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity in the prompt.
|
||||
|
||||
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).
|
||||
## 5. Active Focus & Attention Loop
|
||||
|
||||
### Retrieval Triggers & Active Focus
|
||||
In crowded settings (e.g., a room with many characters), retrieving long-term memories for all co-located entities would exhaust the prompt context window. To prevent this, retrieval uses an **Active Focus** selection strategy:
|
||||
|
||||
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 Set**: The prompt builder scans the last 10 entries of the character's working memory buffer. Any entity targeted by, spoken to, or mentioned in these entries is added to the Active Focus set.
|
||||
* **Dynamic Capacity Limits**:
|
||||
* If the number of co-located characters is small ($\le 3$), long-term memory is retrieved for all of them.
|
||||
* If the environment is crowded ($> 3$), long-term retrieval is strictly restricted to the top 3 characters in the Active Focus set.
|
||||
* This creates a realistic attention loop: when a new character interacts with the actor, they enter the working buffer, triggering the retrieval of their long-term history on the subsequent turn.
|
||||
|
||||
- **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
|
||||
## 6. Prompt Formatting
|
||||
|
||||
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.
|
||||
Retrieved ledger entries are formatted into the prompt chronologically and mapped to subjective aliases. Internal metrics (e.g., importance numbers and raw system IDs) are omitted to preserve immersion.
|
||||
|
||||
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.
|
||||
* Working memory entries are injected under `=== RECENT EVENTS ===` (narrated relative to the present moment).
|
||||
* Recalled ledger entries are injected under `=== YOUR MEMORIES ===` (presented as long-term recollections).
|
||||
|
||||
```text
|
||||
=== RECENT EVENTS ===
|
||||
Moments ago
|
||||
- you spoke to Strider: "Hello there"
|
||||
- You spoke to Strider: "Hello there"
|
||||
|
||||
=== YOUR MEMORIES ===
|
||||
A couple days ago
|
||||
|
||||
Reference in New Issue
Block a user