mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 12:02:49 +05:30
feat: Implement tier 1 memory buffer
This commit is contained in:
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user