feat: Implement tier 1 memory buffer

This commit is contained in:
2026-07-08 21:33:25 +05:30
parent 1aa00532ed
commit 74ffb456a7
12 changed files with 609 additions and 15 deletions

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

@@ -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" }
]
}