mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat: Implement tier 1 memory buffer
This commit is contained in:
125
docs/memory.md
125
docs/memory.md
@@ -0,0 +1,125 @@
|
||||
# Memory & Subjective Aliases
|
||||
|
||||
This document outlines the memory subsystem (`packages/memory`) and the v0 Subjective Alias System. The goal is to enforce epistemic privacy while allowing the LLM-driven components (NPC agents and decoders) to translate naturally between system-level IDs and human-readable narrative context.
|
||||
|
||||
---
|
||||
|
||||
## 1. Subjective Alias System (v0)
|
||||
|
||||
System-level IDs (e.g. `alice`, `bob`, or UUIDs) are critical for state tracking, but placing them directly in prompts violates epistemic privacy and breaks narrative generation (as models make up inconsistent names or fail to match references).
|
||||
|
||||
To solve this, each `Entity` class (in [entity.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/entity.ts)) maintains a private **Subjective Alias Map**:
|
||||
```typescript
|
||||
class Entity extends AttributableObject {
|
||||
locationId: string | null = null;
|
||||
readonly aliases: Map<string, string> = new Map();
|
||||
// Key: target entity ID (e.g., "bob")
|
||||
// Value: subjective string (e.g., "the hooded figure" or "Gareth")
|
||||
}
|
||||
```
|
||||
|
||||
### Alias States
|
||||
* **Unknown Name**: The entity does not know the target's real name. The alias defaults to a subjective label derived from the target's visible description/attributes (e.g., `"the hooded figure"`). This label is used in both internal thoughts and external dialogues.
|
||||
* **Known Name**: The entity has learned the target's name. The alias is updated to their name (e.g., `"Gareth"`).
|
||||
|
||||
### How It Wires Into Prompts
|
||||
* **Intent Decoder**: When decoding narrative prose written by actor `X`, we pass `X`'s alias map to the LLM. This allows the decoder to map subjective labels like *"the hooded figure"* back to the correct system ID `bob`.
|
||||
* **Prompt Injection**: When injecting world state, events, or memories into an NPC's prompt context, the system replaces raw target IDs with the subjective aliases defined in that NPC's alias map.
|
||||
|
||||
### SQLite Persistence
|
||||
Entity aliases are persisted in the `objects` table via the `aliases_json TEXT` column in [repository.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/repository.ts). The aliases map is stringified as JSON entries on save and parsed back upon entity reconstitution in `SQLiteRepository.loadEntity()`, `loadWorldState()`, and `listEntities()`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Subjective Buffer Entry
|
||||
|
||||
A subjective `BufferEntry` records a discrete event from the perspective of an entity (the `owner`). It wraps a structured `Intent` (reused as-is to prevent schema drift) and appends execution metadata. The interface is defined and exported from [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
|
||||
|
||||
### The Shape of a Buffer Entry
|
||||
```typescript
|
||||
interface BufferEntry {
|
||||
id: string;
|
||||
ownerId: string; // Whose subjective memory buffer this lives in
|
||||
timestamp: string; // WorldClock.get().toISOString() at write time
|
||||
locationId: string | null; // Actor's location when this happened
|
||||
|
||||
intent: Intent; // The actual dialogue/action intent, reused as-is
|
||||
outcome?: { // Present only for "action" intents processed by the Architect
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
* **Intent Reuse**: By wrapping the `Intent` directly, any future schema changes to `Intent` flow down automatically without duplicating code.
|
||||
* **Write-time Location**: `locationId` is captured immediately when writing the entry (rather than computed dynamically later), matching the schema of long-term `LedgerEntry` consolidation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Buffer Serialization (Epistemic Substitute)
|
||||
|
||||
To prevent leaking system IDs or universal state to NPCs, we serialize buffer memories using a decoupled, viewer-relative function `serializeSubjectiveBufferEntry` in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
|
||||
|
||||
To safely resolve actor and target entities without leaking internal UUIDs, we use the `resolveAlias` helper:
|
||||
```typescript
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
```
|
||||
|
||||
This helper maps:
|
||||
- Self-references (when an entity evaluates their own memory) to `"you"`.
|
||||
- Known targets to their subjective name/descriptor from the alias map.
|
||||
- Unknown targets to a generic `"an unfamiliar figure"`.
|
||||
|
||||
The primary serialization function consumes this helper:
|
||||
```typescript
|
||||
export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity
|
||||
): string {
|
||||
const dateObj = new Date(entry.timestamp);
|
||||
const timeStr = dateObj.toLocaleTimeString("en-US", { hour12: true, timeZone: "UTC" });
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map(
|
||||
(tid) => resolveAlias(viewer, tid)
|
||||
);
|
||||
|
||||
let details: string;
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
|
||||
} else {
|
||||
details = `${entry.intent.description}`;
|
||||
if (entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timeStr}] ${actorAlias} ${details}`;
|
||||
}
|
||||
```
|
||||
|
||||
This guarantees that the prompt reads cleanly (e.g. `[12:03:00 PM] the hooded figure opened the wooden chest (Outcome: Succeeded)` or `[12:03:00 PM] you spoke to an unfamiliar figure...`) without exposing raw system IDs to the NPC.
|
||||
|
||||
---
|
||||
|
||||
## 4. SQLite Persistence & BufferRepository
|
||||
|
||||
The `BufferRepository` class in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts) utilizes the same SQLite database as core repositories:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS buffer_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
location_id TEXT,
|
||||
intent_json TEXT NOT NULL,
|
||||
outcome_json TEXT,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
* **JSON Storage**: `intent` and `outcome` are serialized/deserialized as raw JSON. Because they are validated by Zod at creation time, they bypass redundant validation checks during database roundtrips.
|
||||
* **Cascade Deletes**: The table is configured with a foreign key referencing the `objects` table (`ON DELETE CASCADE`), ensuring that deleting an entity cleanses all their associated subjective memory entries automatically.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AttributableObject } from "./attribute.js";
|
||||
|
||||
export class Entity extends AttributableObject {
|
||||
locationId: string | null = null;
|
||||
readonly aliases: Map<string, string> = new Map();
|
||||
|
||||
constructor(id?: string, locationId?: string | null) {
|
||||
super(id);
|
||||
|
||||
@@ -22,6 +22,7 @@ export class SQLiteRepository {
|
||||
world_id TEXT,
|
||||
clock_iso TEXT,
|
||||
location_id TEXT,
|
||||
aliases_json TEXT,
|
||||
FOREIGN KEY (world_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -56,6 +57,13 @@ export class SQLiteRepository {
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Safely add aliases_json column if it does not exist in an existing database
|
||||
try {
|
||||
this.db.exec("ALTER TABLE objects ADD COLUMN aliases_json TEXT;");
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
}
|
||||
|
||||
save(obj: AttributableObject, type: string, worldId?: string): void {
|
||||
@@ -66,24 +74,27 @@ export class SQLiteRepository {
|
||||
}
|
||||
|
||||
let locationId: string | null = null;
|
||||
let aliasesJson: string | null = null;
|
||||
if (obj instanceof Entity) {
|
||||
locationId = obj.locationId;
|
||||
aliasesJson = JSON.stringify(Array.from(obj.aliases.entries()));
|
||||
}
|
||||
|
||||
// 1. Insert or ignore the object in the objects table
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO objects (id, type, world_id, clock_iso, location_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
world_id = excluded.world_id,
|
||||
clock_iso = excluded.clock_iso,
|
||||
location_id = excluded.location_id
|
||||
location_id = excluded.location_id,
|
||||
aliases_json = excluded.aliases_json
|
||||
`,
|
||||
)
|
||||
.run(obj.id, type, worldId || null, clockIso, locationId);
|
||||
.run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson);
|
||||
|
||||
// Get current attributes from db to delete the ones that are no longer present
|
||||
const existingAttrs = this.db
|
||||
@@ -169,16 +180,22 @@ export class SQLiteRepository {
|
||||
const objRow = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT type, location_id FROM objects WHERE id = ?
|
||||
SELECT type, location_id, aliases_json FROM objects WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(id) as { type: string; location_id: string | null } | undefined;
|
||||
.get(id) as { type: string; location_id: string | null; aliases_json: string | null } | undefined;
|
||||
|
||||
if (!objRow || objRow.type !== "entity") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = new Entity(id, objRow.location_id);
|
||||
if (objRow.aliases_json) {
|
||||
const entries = JSON.parse(objRow.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
entity.aliases.set(k, v);
|
||||
}
|
||||
}
|
||||
this.reconstituteAttributes(entity);
|
||||
return entity;
|
||||
}
|
||||
@@ -204,13 +221,19 @@ export class SQLiteRepository {
|
||||
const entityRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, location_id FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
SELECT id, location_id, aliases_json FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
`,
|
||||
)
|
||||
.all(id) as { id: string; location_id: string | null }[];
|
||||
.all(id) as { id: string; location_id: string | null; aliases_json: string | null }[];
|
||||
|
||||
for (const row of entityRows) {
|
||||
const entity = new Entity(row.id, row.location_id);
|
||||
if (row.aliases_json) {
|
||||
const entries = JSON.parse(row.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
entity.aliases.set(k, v);
|
||||
}
|
||||
}
|
||||
this.reconstituteAttributes(entity);
|
||||
worldState.addEntity(entity);
|
||||
}
|
||||
@@ -222,14 +245,20 @@ export class SQLiteRepository {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id FROM objects WHERE type = 'entity'
|
||||
SELECT id, aliases_json FROM objects WHERE type = 'entity'
|
||||
`,
|
||||
)
|
||||
.all() as { id: string }[];
|
||||
.all() as { id: string; aliases_json: string | null }[];
|
||||
|
||||
const entities: Entity[] = [];
|
||||
for (const row of rows) {
|
||||
const entity = new Entity(row.id);
|
||||
if (row.aliases_json) {
|
||||
const entries = JSON.parse(row.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
entity.aliases.set(k, v);
|
||||
}
|
||||
}
|
||||
this.reconstituteAttributes(entity);
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
@@ -171,6 +171,26 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("Save and load entity aliases", () => {
|
||||
const db = new Database(":memory:");
|
||||
const repo = new SQLiteRepository(db);
|
||||
|
||||
const world = new WorldState("world-xyz");
|
||||
const alice = new Entity("alice");
|
||||
alice.aliases.set("bob", "the hooded man");
|
||||
alice.aliases.set("charlie", "the baker");
|
||||
world.addEntity(alice);
|
||||
|
||||
repo.saveWorldState(world);
|
||||
|
||||
const loadedWorld = repo.loadWorldState("world-xyz")!;
|
||||
const loadedAlice = loadedWorld.getEntity("alice")!;
|
||||
expect(loadedAlice.aliases.get("bob")).toBe("the hooded man");
|
||||
expect(loadedAlice.aliases.get("charlie")).toBe("the baker");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Serializer & serializeObjectiveWorldState Unit Tests (Tier 1)", () => {
|
||||
|
||||
@@ -20,6 +20,12 @@ export class IntentDecoder {
|
||||
narrativeProse: string,
|
||||
): Promise<IntentSequence> {
|
||||
const entityIds = Array.from(worldState.entities.keys());
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext = aliasEntries.length > 0
|
||||
? aliasEntries.map(([targetId, alias]) => `- "${alias}" refers to entity ID: "${targetId}"`).join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
@@ -32,7 +38,7 @@ For each intent you must:
|
||||
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 only IDs from the known entities list. If no specific target, use an empty array.
|
||||
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.
|
||||
|
||||
Rules:
|
||||
- Preserve the chronological order of intents as they appear in the prose.
|
||||
@@ -46,6 +52,10 @@ Rules:
|
||||
=== KNOWN ENTITY IDS ===
|
||||
${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
|
||||
|
||||
=== ACTOR ALIASES ===
|
||||
The actor refers to other entities using these subjective names/aliases:
|
||||
${aliasContext}
|
||||
|
||||
=== WORLD STATE ===
|
||||
${serializeObjectiveWorldState(worldState)}
|
||||
|
||||
|
||||
@@ -5,5 +5,10 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
161
packages/memory/src/buffer.ts
Normal file
161
packages/memory/src/buffer.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
|
||||
export interface BufferEntry {
|
||||
id: string;
|
||||
ownerId: string; // Whose subjective memory buffer this lives in
|
||||
timestamp: string; // WorldClock.get().toISOString() at write time
|
||||
locationId: string | null; // Actor's location when this happened
|
||||
|
||||
intent: Intent; // The actual dialogue/action intent, reused as-is
|
||||
outcome?: {
|
||||
// Present only for "action" intents processed by the Architect
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
|
||||
export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity,
|
||||
): string {
|
||||
const dateObj = new Date(entry.timestamp);
|
||||
// Ensure a deterministic timezone/format for testing and model inputs:
|
||||
const timeStr = dateObj.toLocaleTimeString("en-US", {
|
||||
hour12: true,
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map((tid) =>
|
||||
resolveAlias(viewer, tid),
|
||||
);
|
||||
|
||||
let details: string;
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
|
||||
} else {
|
||||
details = `${entry.intent.description}`;
|
||||
if (entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timeStr}] ${actorAlias} ${details}`;
|
||||
}
|
||||
|
||||
export class BufferRepository {
|
||||
constructor(private db: Database.Database) {
|
||||
// Enable foreign keys for cascading deletes
|
||||
this.db.exec("PRAGMA foreign_keys = ON;");
|
||||
this.initializeSchema();
|
||||
}
|
||||
|
||||
private initializeSchema(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS buffer_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
location_id TEXT,
|
||||
intent_json TEXT NOT NULL,
|
||||
outcome_json TEXT,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
save(entry: BufferEntry): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json)
|
||||
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
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
entry.id,
|
||||
entry.ownerId,
|
||||
entry.timestamp,
|
||||
entry.locationId,
|
||||
JSON.stringify(entry.intent),
|
||||
entry.outcome ? JSON.stringify(entry.outcome) : null,
|
||||
);
|
||||
}
|
||||
|
||||
load(id: string): BufferEntry | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
FROM buffer_entries WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(id) as
|
||||
| {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
timestamp: string;
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
listForOwner(ownerId: string): BufferEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
FROM buffer_entries WHERE owner_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`,
|
||||
)
|
||||
.all(ownerId) as {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
timestamp: string;
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
}[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare(`DELETE FROM buffer_entries WHERE id = ?`).run(id);
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export * from "./buffer.js";
|
||||
|
||||
200
packages/memory/tests/memory.test.ts
Normal file
200
packages/memory/tests/memory.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity, SQLiteRepository } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
serializeSubjectiveBufferEntry,
|
||||
resolveAlias,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
test("resolveAlias correctly handles self and fallbacks", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
|
||||
expect(resolveAlias(viewer, "alice")).toBe("you");
|
||||
expect(resolveAlias(viewer, "bob")).toBe("the hooded figure");
|
||||
expect(resolveAlias(viewer, "charlie")).toBe("an unfamiliar figure");
|
||||
});
|
||||
|
||||
test("serializes dialogue intent substituting target/actor aliases", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
viewer.aliases.set("charlie", "the bartender");
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "entry-1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:00:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: '"Hello there," Bob said to Charlie.',
|
||||
description: "Bob greets Charlie",
|
||||
actorId: "bob",
|
||||
targetIds: ["charlie"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('[12:00:00 PM] the hooded figure spoke to the bartender: "Bob greets Charlie"');
|
||||
});
|
||||
|
||||
test("serializes action intent with outcome details", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "entry-2",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:05:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Bob tried to break the latch.",
|
||||
description: "Bob attempts to break the lock latch",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
},
|
||||
outcome: {
|
||||
isValid: false,
|
||||
reason: "The lock is made of reinforced steel.",
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('[12:05:00 PM] the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
});
|
||||
|
||||
test("serializes self-reference and unfamiliar actors", () => {
|
||||
const viewer = new Entity("alice");
|
||||
|
||||
const entrySelf: BufferEntry = {
|
||||
id: "entry-self",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:10:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "I opened the window.",
|
||||
description: "open the window",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("[12:10:00 PM] you open the window");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:15:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Someone knocked.",
|
||||
description: "knock on the door",
|
||||
actorId: "stranger-1",
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
|
||||
expect(resultUnfamiliar).toBe("[12:15:00 PM] an unfamiliar figure knock on the door");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
test("saves, loads, lists, and deletes buffer entries in SQLite database", () => {
|
||||
const db = new Database(":memory:");
|
||||
|
||||
// We need SQLiteRepository to initialize the objects table because buffer_entries depends on objects(id) via FK
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const repo = new BufferRepository(db);
|
||||
|
||||
// Add owner entity to objects table
|
||||
const alice = new Entity("alice");
|
||||
coreRepo.saveEntity(alice);
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "Alice picked up a stick.",
|
||||
description: "Alice gathers a stick",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
};
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "buf-1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T14:30:00.000Z",
|
||||
locationId: "forest",
|
||||
intent,
|
||||
outcome: {
|
||||
isValid: true,
|
||||
reason: "There are many dry sticks on the ground.",
|
||||
},
|
||||
};
|
||||
|
||||
// Save
|
||||
repo.save(entry);
|
||||
|
||||
// Load
|
||||
const loaded = repo.load("buf-1");
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.id).toBe("buf-1");
|
||||
expect(loaded!.ownerId).toBe("alice");
|
||||
expect(loaded!.timestamp).toBe("2026-07-07T14:30:00.000Z");
|
||||
expect(loaded!.locationId).toBe("forest");
|
||||
expect(loaded!.intent).toEqual(intent);
|
||||
expect(loaded!.outcome).toEqual(entry.outcome);
|
||||
|
||||
// List
|
||||
const list = repo.listForOwner("alice");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].id).toBe("buf-1");
|
||||
|
||||
// Delete
|
||||
repo.delete("buf-1");
|
||||
expect(repo.load("buf-1")).toBeNull();
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("cascades delete of buffer entries when owner entity is deleted", () => {
|
||||
const db = new Database(":memory:");
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const repo = new BufferRepository(db);
|
||||
|
||||
const alice = new Entity("alice");
|
||||
coreRepo.saveEntity(alice);
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "buf-1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T14:30:00.000Z",
|
||||
locationId: "forest",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Alice sneezed.",
|
||||
description: "Alice sneezes",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
repo.save(entry);
|
||||
expect(repo.load("buf-1")).not.toBeNull();
|
||||
|
||||
// Delete owner entity from core repository
|
||||
coreRepo.delete("alice");
|
||||
|
||||
// Verify buffer entry was cascade deleted
|
||||
expect(repo.load("buf-1")).toBeNull();
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -5,5 +5,8 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" }
|
||||
]
|
||||
}
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -342,7 +342,17 @@ importers:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
|
||||
packages/memory: {}
|
||||
packages/memory:
|
||||
dependencies:
|
||||
'@omnia/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@omnia/intent':
|
||||
specifier: workspace:*
|
||||
version: link:../intent
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
packages/spatial:
|
||||
dependencies:
|
||||
|
||||
@@ -41,5 +41,35 @@ describe("IntentDecoder Live Eval (Tier 3)", () => {
|
||||
expect(actionIntent).toBeDefined();
|
||||
expect(actionIntent!.actorId).toBe("alice");
|
||||
expect(actionIntent!.originalText).toContain("slipped the silver key");
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
test("resolves targets correctly using the actor's subjective alias map", async () => {
|
||||
expect(llmConfig.GOOGLE_API_KEY).toBeDefined();
|
||||
|
||||
const provider = new GeminiProvider(llmConfig.GOOGLE_API_KEY);
|
||||
const decoder = new IntentDecoder(provider);
|
||||
|
||||
const world = new WorldState("world-xyz");
|
||||
const alice = new Entity("alice");
|
||||
const bob = new Entity("bob");
|
||||
|
||||
// Register alias mapping: Bob is known to Alice as "the hooded stranger"
|
||||
alice.aliases.set("bob", "the hooded stranger");
|
||||
|
||||
world.addEntity(alice);
|
||||
world.addEntity(bob);
|
||||
|
||||
// Narrative prose referring to Bob only by the subjective alias
|
||||
const narrativeProse = "Alice handed the silver key to the hooded stranger.";
|
||||
|
||||
const result = await decoder.decode(world, "alice", narrativeProse);
|
||||
|
||||
expect(result.intents).toHaveLength(1);
|
||||
const intent = result.intents[0];
|
||||
|
||||
expect(intent.type).toBe("action");
|
||||
expect(intent.actorId).toBe("alice");
|
||||
// Verify target resolution resolved the alias back to the correct system entity ID
|
||||
expect(intent.targetIds).toContain("bob");
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user