2 Commits

14 changed files with 792 additions and 15 deletions

View File

@@ -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.

View File

@@ -17,3 +17,90 @@ export class WorldClock {
return new WorldClock(new Date(iso));
}
}
/**
* Naturalizes a standard datetime format into a psychologically realistic subjective duration.
* Resolves quantized time formats into natural language phrases based on the relationship
* between a reference/current time and a past event time.
*/
export function naturalizeTime(now: Date, past: Date): string {
const deltaMs = now.getTime() - past.getTime();
// Guard against future dates
if (deltaMs <= 0) {
return "just now";
}
const deltaSeconds = Math.floor(deltaMs / 1000);
const deltaMinutes = Math.floor(deltaSeconds / 60);
const deltaHours = Math.floor(deltaMinutes / 60);
const deltaDays = Math.floor(deltaHours / 24);
// --- Tier 1: Pure Relative (delta < 6 hours) ---
if (deltaHours < 6) {
if (deltaMinutes < 1) return "just now";
if (deltaMinutes < 3) return "moments ago";
if (deltaMinutes < 10) return "a few minutes ago";
if (deltaMinutes < 30) return "several minutes ago";
if (deltaMinutes < 45) return "about half an hour ago";
if (deltaHours < 1.5) return "about an hour ago";
if (deltaHours < 3) return "a couple hours ago";
return "a few hours ago";
}
// --- Tier 3: Coarse (delta >= 48 hours / 2 days) ---
if (deltaDays >= 2) {
if (deltaDays < 3) return "a couple days ago";
if (deltaDays < 7) return "a few days ago";
if (deltaDays < 14) return "about a week ago";
if (deltaDays < 21) return "a couple weeks ago";
if (deltaDays < 30) return "a few weeks ago";
if (deltaDays < 60) return "about a month ago";
if (deltaDays < 90) return "a couple months ago";
if (deltaDays < 180) return "a few months ago";
if (deltaDays < 365) return "many months ago";
if (deltaDays < 730) return "about a year ago";
return "years ago";
}
// --- Tier 2: Period-Anchored (6 hours <= delta < 48 hours) ---
const pastHour = past.getHours();
let period: string;
if (pastHour >= 5 && pastHour < 12) {
period = "morning";
} else if (pastHour >= 12 && pastHour < 17) {
period = "afternoon";
} else if (pastHour >= 17 && pastHour < 21) {
period = "evening";
} else if (pastHour >= 21 && pastHour < 24) {
period = "night";
} else if (pastHour >= 0 && pastHour < 3) {
period = "midnight";
} else {
period = "late night";
}
// Subjective day test: waking hours (05:00 - 20:59)
const nowHour = now.getHours();
const pastIsWaking = pastHour >= 5 && pastHour < 22;
const nowIsWaking = nowHour >= 5 && nowHour < 22;
const isSameSubjectiveDay = pastIsWaking && nowIsWaking && deltaHours < 18;
if (isSameSubjectiveDay) {
return `earlier today, in the ${period}`;
}
if (period === "night") {
return "last night";
}
if (period === "midnight") {
return "around midnight";
}
if (period === "late night") {
return "late last night";
}
return `yesterday ${period}`;
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -0,0 +1,96 @@
import { describe, test, expect } from "vitest";
import { naturalizeTime } from "../src/clock.js";
describe("TimeNaturalization Unit Tests (Tier 1)", () => {
test("future or current times return just now", () => {
const now = new Date(2026, 6, 8, 12, 0, 0);
const pastFuture = new Date(2026, 6, 8, 12, 0, 5);
expect(naturalizeTime(now, pastFuture)).toBe("just now");
expect(naturalizeTime(now, now)).toBe("just now");
});
test("Tier 1: relative time ranges (< 6 hours)", () => {
const now = new Date(2026, 6, 8, 12, 0, 0);
// < 1 minute
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 59, 30))).toBe("just now");
// < 3 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 58, 0))).toBe("moments ago");
// < 10 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 52, 0))).toBe("a few minutes ago");
// < 30 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 40, 0))).toBe("several minutes ago");
// < 45 minutes
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 20, 0))).toBe("about half an hour ago");
// < 1.5 hours (90 minutes)
expect(naturalizeTime(now, new Date(2026, 6, 8, 10, 50, 0))).toBe("about an hour ago");
// < 3 hours
expect(naturalizeTime(now, new Date(2026, 6, 8, 9, 30, 0))).toBe("a couple hours ago");
// < 6 hours
expect(naturalizeTime(now, new Date(2026, 6, 8, 7, 0, 0))).toBe("a few hours ago");
});
test("Tier 2: Same subjective day vs yesterday periods (6h <= delta < 48h)", () => {
// 1. Same subjective day (both waking hours, delta < 18h)
// 9am to 3pm (delta 6h) -> morning
const nowWaking = new Date(2026, 6, 8, 15, 0, 0);
const pastMorning = new Date(2026, 6, 8, 9, 0, 0);
expect(naturalizeTime(nowWaking, pastMorning)).toBe("earlier today, in the morning");
// 2pm to 9pm (delta 7h) -> afternoon
const nowEvening = new Date(2026, 6, 8, 21, 0, 0);
const pastAfternoon = new Date(2026, 6, 8, 14, 0, 0);
expect(naturalizeTime(nowEvening, pastAfternoon)).toBe("earlier today, in the afternoon");
// 2. Previous night (past period: night (21-23))
// 11pm to 5am (delta 6h) -> last night
const nowNightRun = new Date(2026, 6, 8, 5, 0, 0);
const pastNight = new Date(2026, 6, 7, 23, 0, 0);
expect(naturalizeTime(nowNightRun, pastNight)).toBe("last night");
// 3. Around midnight (past period: midnight (0-2))
// 1am to 7am (delta 6h) -> around midnight
const nowMidnightRun = new Date(2026, 6, 8, 7, 0, 0);
const pastMidnight = new Date(2026, 6, 8, 1, 0, 0);
expect(naturalizeTime(nowMidnightRun, pastMidnight)).toBe("around midnight");
// 4. Late night (past period: late night (3-4))
// 3am to 9am (delta 6h) -> late last night
const nowLateNightRun = new Date(2026, 6, 8, 9, 0, 0);
const pastLateNight = new Date(2026, 6, 8, 3, 0, 0);
expect(naturalizeTime(nowLateNightRun, pastLateNight)).toBe("late last night");
// 5. Yesterday waking hours (delta >= 6h, diff day / waking check fails)
// 3pm to 9am next day (delta 18h) -> yesterday afternoon
const nowNextDay = new Date(2026, 6, 9, 9, 0, 0);
const pastYesterdayAfternoon = new Date(2026, 6, 8, 15, 0, 0);
expect(naturalizeTime(nowNextDay, pastYesterdayAfternoon)).toBe("yesterday afternoon");
});
test("Tier 3: Coarse relative time ranges (delta >= 48 hours)", () => {
const now = new Date(2026, 6, 10, 12, 0, 0);
// 2 days
expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 0, 0))).toBe("a couple days ago");
// 3-6 days
expect(naturalizeTime(now, new Date(2026, 6, 6, 12, 0, 0))).toBe("a few days ago");
// 7-13 days
expect(naturalizeTime(now, new Date(2026, 6, 1, 12, 0, 0))).toBe("about a week ago");
// 14-20 days
expect(naturalizeTime(now, new Date(2026, 5, 25, 12, 0, 0))).toBe("a couple weeks ago");
// 21-29 days
expect(naturalizeTime(now, new Date(2026, 5, 15, 12, 0, 0))).toBe("a few weeks ago");
// 30-59 days
expect(naturalizeTime(now, new Date(2026, 5, 1, 12, 0, 0))).toBe("about a month ago");
// 60-89 days
expect(naturalizeTime(now, new Date(2026, 4, 1, 12, 0, 0))).toBe("a couple months ago");
// 90-179 days
expect(naturalizeTime(now, new Date(2026, 3, 1, 12, 0, 0))).toBe("a few months ago");
// 180-364 days
expect(naturalizeTime(now, new Date(2026, 0, 1, 12, 0, 0))).toBe("many months ago");
// 365-729 days
expect(naturalizeTime(now, new Date(2025, 6, 1, 12, 0, 0))).toBe("about a year ago");
// >= 730 days
expect(naturalizeTime(now, new Date(2023, 6, 1, 12, 0, 0))).toBe("years ago");
});
});

View File

@@ -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)", () => {

View File

@@ -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)}

View File

@@ -5,5 +5,10 @@
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/intent": "workspace:*",
"zod": "^4.4.3"
}
}

View 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);
}
}

View File

@@ -1 +1 @@
export {};
export * from "./buffer.js";

View 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();
});
});

View File

@@ -5,5 +5,8 @@
"outDir": "dist"
},
"include": ["src"],
"references": []
"references": [
{ "path": "../core" },
{ "path": "../intent" }
]
}

12
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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);
});