mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat(memory): Implemented tier two memory retrieval using cognition model
This commit is contained in:
@@ -17,7 +17,7 @@ for (const c of envCandidates) {
|
||||
}
|
||||
}
|
||||
|
||||
import { BufferRepository } from "@omnia/memory";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
@@ -89,6 +89,7 @@ interface SimSession {
|
||||
dbPath: string;
|
||||
coreRepo: SQLiteRepository;
|
||||
bufferRepo: BufferRepository;
|
||||
ledgerRepo: LedgerRepository;
|
||||
worldInstanceId: string;
|
||||
scenarioName: string;
|
||||
scenarioDescription: string;
|
||||
@@ -154,6 +155,7 @@ class SimulationManager {
|
||||
const db = new Database(dbPath);
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
const loader = new ScenarioLoader(coreRepo, bufferRepo);
|
||||
|
||||
const worldInstanceId = id;
|
||||
@@ -258,6 +260,7 @@ class SimulationManager {
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId,
|
||||
scenarioName: scenarioJson.name,
|
||||
scenarioDescription: scenarioJson.description,
|
||||
@@ -353,6 +356,7 @@ class SimulationManager {
|
||||
const playerActor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
new FixedProseGenerator(prose),
|
||||
);
|
||||
@@ -463,7 +467,7 @@ class SimulationManager {
|
||||
const entity = worldState.getEntity(info.id);
|
||||
if (!entity) throw new Error(`Entity "${info.id}" not found`);
|
||||
|
||||
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, 20);
|
||||
const promptBuilder = new ActorPromptBuilder(session.bufferRepo, session.ledgerRepo, 20);
|
||||
const { systemPrompt, userContext } = promptBuilder.build(
|
||||
worldState,
|
||||
entity,
|
||||
@@ -493,6 +497,7 @@ class SimulationManager {
|
||||
const actor = new ActorAgent(
|
||||
{ actor: session.actorProvider, decoder: session.decoderProvider },
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
20,
|
||||
);
|
||||
const result = await actor.act(worldState, entity);
|
||||
@@ -681,6 +686,7 @@ class SimulationManager {
|
||||
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
|
||||
const actorProvider = resolveProviderForTask("actor-prose");
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
@@ -698,6 +704,7 @@ class SimulationManager {
|
||||
dbPath,
|
||||
coreRepo,
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId: id,
|
||||
scenarioName: state.scenarioName,
|
||||
scenarioDescription: state.scenarioDescription,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
serializeSubjectiveBufferEntry,
|
||||
LedgerEntry,
|
||||
LedgerRepository,
|
||||
} from "@omnia/memory";
|
||||
|
||||
/**
|
||||
@@ -37,12 +39,17 @@ export class ActorPromptBuilder {
|
||||
/**
|
||||
* @param bufferRepo Used to fetch the actor's recent memory. Optional —
|
||||
* if absent, the memory section is omitted.
|
||||
* @param ledgerRepo Used to fetch long-term memories. Optional.
|
||||
* @param memoryLimit Maximum number of recent buffer entries to inject.
|
||||
* Defaults to 20.
|
||||
* @param ledgerLimit Maximum number of long-term memories to retrieve.
|
||||
* Defaults to 5.
|
||||
*/
|
||||
constructor(
|
||||
private bufferRepo?: BufferRepository,
|
||||
private ledgerRepo?: LedgerRepository,
|
||||
private memoryLimit = 20,
|
||||
private ledgerLimit = 5,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -74,15 +81,14 @@ Guidelines:
|
||||
- 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.
|
||||
- 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.
|
||||
".
|
||||
`.trim();
|
||||
}
|
||||
|
||||
private buildUserContext(worldState: WorldState, entity: Entity): string {
|
||||
const sections: string[] = [];
|
||||
const now = worldState.clock.get();
|
||||
|
||||
// --- Subjective present time ---
|
||||
const now = worldState.clock.get();
|
||||
sections.push(
|
||||
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
|
||||
);
|
||||
@@ -92,30 +98,43 @@ Guidelines:
|
||||
`=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`,
|
||||
);
|
||||
|
||||
// Fetch recent buffer entries once
|
||||
let recentEntries: BufferEntry[] = [];
|
||||
if (this.bufferRepo) {
|
||||
try {
|
||||
recentEntries = this.bufferRepo.listForOwner(entity.id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// --- Recent memory ---
|
||||
const memorySection = this.buildMemorySection(
|
||||
entity,
|
||||
worldState.clock.get(),
|
||||
);
|
||||
const memorySection = this.buildMemorySection(entity, recentEntries, now);
|
||||
if (memorySection) {
|
||||
sections.push(memorySection);
|
||||
}
|
||||
|
||||
// --- Recalled Long-Term memory ---
|
||||
const ledgerSection = this.buildLedgerSection(
|
||||
worldState,
|
||||
entity,
|
||||
recentEntries,
|
||||
now,
|
||||
);
|
||||
if (ledgerSection) {
|
||||
sections.push(ledgerSection);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
private buildMemorySection(entity: Entity, now: Date): string | null {
|
||||
private buildMemorySection(
|
||||
entity: Entity,
|
||||
entries: BufferEntry[],
|
||||
now: Date,
|
||||
): string | null {
|
||||
if (!this.bufferRepo) return null;
|
||||
|
||||
let entries: BufferEntry[];
|
||||
try {
|
||||
entries = this.bufferRepo.listForOwner(entity.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return `=== YOUR RECENT MEMORY ===\n(You have no memories yet.)`;
|
||||
return `=== RECENT EVENTS ===\n(No recent events recorded.)`;
|
||||
}
|
||||
|
||||
const recent = entries.slice(-this.memoryLimit);
|
||||
@@ -135,6 +154,110 @@ Guidelines:
|
||||
groupedLines.push(` - ${serialized}`);
|
||||
}
|
||||
|
||||
return `=== YOUR RECENT MEMORY ===\n${groupedLines.join("\n")}`;
|
||||
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`;
|
||||
}
|
||||
|
||||
private buildLedgerSection(
|
||||
worldState: WorldState,
|
||||
entity: Entity,
|
||||
recentBuffer: BufferEntry[],
|
||||
now: Date,
|
||||
): string | null {
|
||||
if (!this.ledgerRepo) return null;
|
||||
|
||||
// 1. Get co-located entities (in the same location as entity)
|
||||
const coLocatedEntityIds: string[] = [];
|
||||
if (entity.locationId) {
|
||||
for (const e of worldState.entities.values()) {
|
||||
if (e.id !== entity.id && e.locationId === entity.locationId) {
|
||||
coLocatedEntityIds.push(e.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Compute Active Focus entities based on recent interactions (last 10 entries)
|
||||
const activeFocus = new Set<string>();
|
||||
const maxFocus = 3;
|
||||
|
||||
// We scan the recent buffer entries to see who we recently talked to or who talked to us
|
||||
for (let i = recentBuffer.length - 1; i >= 0; i--) {
|
||||
const entry = recentBuffer[i];
|
||||
const intent = entry.intent;
|
||||
|
||||
if (
|
||||
intent.actorId !== entity.id &&
|
||||
coLocatedEntityIds.includes(intent.actorId)
|
||||
) {
|
||||
activeFocus.add(intent.actorId);
|
||||
}
|
||||
for (const targetId of intent.targetIds) {
|
||||
if (targetId !== entity.id && coLocatedEntityIds.includes(targetId)) {
|
||||
activeFocus.add(targetId);
|
||||
}
|
||||
}
|
||||
if (activeFocus.size >= maxFocus) break;
|
||||
}
|
||||
|
||||
// If co-located entities is small, auto-focus all of them
|
||||
if (activeFocus.size < maxFocus && coLocatedEntityIds.length <= maxFocus) {
|
||||
for (const id of coLocatedEntityIds) {
|
||||
if (id !== entity.id) {
|
||||
activeFocus.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeFocusIds = Array.from(activeFocus);
|
||||
|
||||
// 3. Retrieve memories using Active Focus
|
||||
let recalled: LedgerEntry[];
|
||||
try {
|
||||
recalled = this.ledgerRepo.retrieve(
|
||||
entity.id,
|
||||
entity.locationId,
|
||||
activeFocusIds,
|
||||
undefined, // no query embedding for now (Recency + Importance ranking)
|
||||
now,
|
||||
this.ledgerLimit,
|
||||
{ includeAssociativeNeighbors: true },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recalled.length === 0) return null;
|
||||
|
||||
// 4. Format them identical to the recent memory format
|
||||
const groupedLines: string[] = [];
|
||||
let currentGroup: string | null = null;
|
||||
|
||||
for (const entry of recalled) {
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
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;
|
||||
content = content.replace(new RegExp(targetId, "g"), alias);
|
||||
}
|
||||
if (entry.locationId) {
|
||||
content += ` (at ${entry.locationId})`;
|
||||
}
|
||||
|
||||
if (when !== currentGroup) {
|
||||
currentGroup = when;
|
||||
const header = when.charAt(0).toUpperCase() + when.slice(1);
|
||||
groupedLines.push(header);
|
||||
}
|
||||
|
||||
groupedLines.push(` - ${content}`);
|
||||
if (entry.quotes && entry.quotes.length > 0) {
|
||||
for (const quote of entry.quotes) {
|
||||
groupedLines.push(` Quote: "${quote}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `=== YOUR MEMORIES ===\n${groupedLines.join("\n")}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ILLMProvider } from "@omnia/llm";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
} from "@omnia/memory";
|
||||
import {
|
||||
Intent,
|
||||
@@ -73,6 +74,7 @@ export class ActorAgent {
|
||||
constructor(
|
||||
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
|
||||
bufferRepo?: BufferRepository,
|
||||
ledgerRepo?: LedgerRepository,
|
||||
memoryLimit?: number,
|
||||
generator?: IActorProseGenerator,
|
||||
) {
|
||||
@@ -87,7 +89,7 @@ export class ActorAgent {
|
||||
decoderProv = llmProvider;
|
||||
}
|
||||
|
||||
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
|
||||
this.promptBuilder = new ActorPromptBuilder(bufferRepo, ledgerRepo, memoryLimit);
|
||||
this.decoder = new IntentDecoder(decoderProv);
|
||||
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
|
||||
this.llmProvider = actorProv;
|
||||
|
||||
100
packages/actor/tests/actor-prompt-builder.test.ts
Normal file
100
packages/actor/tests/actor-prompt-builder.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, Entity, AttributeVisibility } from "@omnia/core";
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { ActorPromptBuilder } from "../src/actor-prompt-builder";
|
||||
|
||||
describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
|
||||
let db: Database.Database;
|
||||
let bufferRepo: BufferRepository;
|
||||
let ledgerRepo: LedgerRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
|
||||
// Core database schemas for testing
|
||||
db.exec(`
|
||||
CREATE TABLE objects (
|
||||
id TEXT PRIMARY KEY
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO objects (id) VALUES ('alice'), ('bob'), ('charlie');
|
||||
`);
|
||||
|
||||
bufferRepo = new BufferRepository(db);
|
||||
ledgerRepo = new LedgerRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("should inject both recent memory and recalled long-term memory with subjective aliases resolved", () => {
|
||||
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
|
||||
|
||||
const alice = new Entity("alice", "tavern");
|
||||
// Add subjective alias for bob
|
||||
alice.aliases.set("bob", "Strider");
|
||||
world.addEntity(alice);
|
||||
|
||||
const bob = new Entity("bob", "tavern");
|
||||
world.addEntity(bob);
|
||||
|
||||
// 1. Populate recent buffer memory
|
||||
bufferRepo.save({
|
||||
id: "buf1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-10T11:58:00.000Z", // 2 mins ago
|
||||
locationId: "tavern",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
originalText: "Hello there",
|
||||
description: "Alice greets Bob",
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Populate ledger repository (long-term memory)
|
||||
ledgerRepo.save({
|
||||
id: "ledger1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-08T12:00:00.000Z", // 2 days ago
|
||||
locationId: "tavern",
|
||||
involvedEntityIds: ["bob"],
|
||||
content: "alice met bob at the tavern.",
|
||||
quotes: ["I am a ranger."],
|
||||
importance: 9,
|
||||
embedding: [],
|
||||
});
|
||||
|
||||
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
|
||||
const { userContext } = builder.build(world, alice);
|
||||
|
||||
// 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 ===");
|
||||
// Bob should be resolved to Strider in the ledger content
|
||||
expect(userContext).toContain("alice met Strider at the tavern.");
|
||||
expect(userContext).toContain('Quote: "I am a ranger."');
|
||||
});
|
||||
|
||||
it("should not explode if ledger contains no memories or is empty", () => {
|
||||
const world = new WorldState("world-123", new Date("2024-01-10T12:00:00.000Z"));
|
||||
const alice = new Entity("alice", "tavern");
|
||||
world.addEntity(alice);
|
||||
|
||||
const builder = new ActorPromptBuilder(bufferRepo, ledgerRepo, 20, 5);
|
||||
const { userContext } = builder.build(world, alice);
|
||||
|
||||
expect(userContext).toContain("=== RECENT EVENTS ===");
|
||||
expect(userContext).not.toContain("=== YOUR MEMORIES ===");
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,31 @@ export class LedgerRepository {
|
||||
})();
|
||||
}
|
||||
|
||||
private mapRowToEntry(row: any, involvedEntityIds: string[]): LedgerEntry {
|
||||
let embedding: number[] = [];
|
||||
if (row.embedding) {
|
||||
const buffer = row.embedding as Buffer;
|
||||
const floatArray = new Float32Array(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength / Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
embedding = Array.from(floatArray);
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
involvedEntityIds,
|
||||
content: row.content,
|
||||
quotes: JSON.parse(row.quotes_json || "[]"),
|
||||
importance: row.importance,
|
||||
embedding: embedding,
|
||||
};
|
||||
}
|
||||
|
||||
load(id: string): LedgerEntry | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
@@ -113,28 +138,7 @@ export class LedgerRepository {
|
||||
)
|
||||
.all(id) as { entity_id: string }[];
|
||||
|
||||
let embedding: number[] = [];
|
||||
if (row.embedding) {
|
||||
const buffer = row.embedding as Buffer;
|
||||
const floatArray = new Float32Array(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength / Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
embedding = Array.from(floatArray);
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
involvedEntityIds: entitiesRows.map((er) => er.entity_id),
|
||||
content: row.content,
|
||||
quotes: JSON.parse(row.quotes_json),
|
||||
importance: row.importance,
|
||||
embedding: embedding,
|
||||
};
|
||||
return this.mapRowToEntry(row, entitiesRows.map((er) => er.entity_id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,32 +206,174 @@ export class LedgerRepository {
|
||||
entitiesMap.get(er.entry_id)!.push(er.entity_id);
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
let embedding: number[] = [];
|
||||
if (row.embedding) {
|
||||
const buffer = row.embedding as Buffer;
|
||||
const floatArray = new Float32Array(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength / Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
embedding = Array.from(floatArray);
|
||||
return rows.map((row) => this.mapRowToEntry(row, entitiesMap.get(row.id) || []));
|
||||
}
|
||||
|
||||
private fetchRawNeighbors(ownerId: string, timestamp: string): LedgerEntry[] {
|
||||
const neighbors: LedgerEntry[] = [];
|
||||
|
||||
// Preceding entry
|
||||
const preceding = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
|
||||
FROM ledger_entries
|
||||
WHERE owner_id = ? AND timestamp < ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
.get(ownerId, timestamp) as any;
|
||||
|
||||
if (preceding) {
|
||||
neighbors.push(this.mapRowToEntry(preceding, []));
|
||||
}
|
||||
|
||||
// Succeeding entry
|
||||
const succeeding = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
|
||||
FROM ledger_entries
|
||||
WHERE owner_id = ? AND timestamp > ?
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
.get(ownerId, timestamp) as any;
|
||||
|
||||
if (succeeding) {
|
||||
neighbors.push(this.mapRowToEntry(succeeding, []));
|
||||
}
|
||||
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 + Phase 2 Retrieval Pipeline
|
||||
* 1. Fetches candidates via Phase 1 heuristic filtering.
|
||||
* 2. Ranks them using: Score = Recency + Importance + Semantic Match.
|
||||
* 3. Selects the top `limit` memories.
|
||||
* 4. Optionally pulls in the immediate chronological neighbors (associative chain).
|
||||
* 5. Returns all gathered entries sorted chronologically (timestamp ASC).
|
||||
*/
|
||||
retrieve(
|
||||
ownerId: string,
|
||||
currentLocationId: string | null,
|
||||
currentInvolvedEntityIds: string[],
|
||||
queryEmbedding?: number[],
|
||||
now: Date = new Date(),
|
||||
limit: number = 5,
|
||||
options?: {
|
||||
includeAssociativeNeighbors?: boolean;
|
||||
recencyWeight?: number;
|
||||
importanceWeight?: number;
|
||||
relevanceWeight?: number;
|
||||
decayRate?: number;
|
||||
}
|
||||
): LedgerEntry[] {
|
||||
const includeAssociativeNeighbors = options?.includeAssociativeNeighbors ?? false;
|
||||
const recencyWeight = options?.recencyWeight ?? 1.0;
|
||||
const importanceWeight = options?.importanceWeight ?? 1.0;
|
||||
const relevanceWeight = options?.relevanceWeight ?? 1.0;
|
||||
const decayRate = options?.decayRate ?? 0.99;
|
||||
|
||||
// Fetch candidate pool (limit 100 to provide enough options for Phase 2 ranking)
|
||||
const candidates = this.getRelevant(ownerId, currentLocationId, currentInvolvedEntityIds, 100);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// Score candidates
|
||||
const scored = candidates.map((entry) => {
|
||||
// Recency calculation with exponential decay
|
||||
const deltaMs = now.getTime() - new Date(entry.timestamp).getTime();
|
||||
const hoursElapsed = Math.max(0, deltaMs / (3600 * 1000));
|
||||
const recency = Math.pow(decayRate, hoursElapsed);
|
||||
|
||||
// Importance score normalized (0.0 to 1.0)
|
||||
const importanceNorm = entry.importance / 10.0;
|
||||
|
||||
// Semantic relevance
|
||||
let relevance = 0;
|
||||
if (queryEmbedding && entry.embedding && entry.embedding.length > 0) {
|
||||
relevance = cosineSimilarity(queryEmbedding, entry.embedding);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
involvedEntityIds: entitiesMap.get(row.id) || [],
|
||||
content: row.content,
|
||||
quotes: JSON.parse(row.quotes_json),
|
||||
importance: row.importance,
|
||||
embedding: embedding,
|
||||
};
|
||||
|
||||
const score =
|
||||
recencyWeight * recency +
|
||||
importanceWeight * importanceNorm +
|
||||
relevanceWeight * relevance;
|
||||
|
||||
return { entry, score };
|
||||
});
|
||||
|
||||
// Rank and take top memories
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const selected = scored.slice(0, limit).map((s) => s.entry);
|
||||
|
||||
let finalEntries = [...selected];
|
||||
|
||||
// Optionally retrieve associative neighbors
|
||||
if (includeAssociativeNeighbors && selected.length > 0) {
|
||||
const neighborMap = new Map<string, LedgerEntry>();
|
||||
|
||||
for (const entry of selected) {
|
||||
const rawNeighbors = this.fetchRawNeighbors(ownerId, entry.timestamp);
|
||||
for (const rn of rawNeighbors) {
|
||||
if (!finalEntries.some((fe) => fe.id === rn.id) && !neighborMap.has(rn.id)) {
|
||||
neighborMap.set(rn.id, rn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neighborsToPopulate = Array.from(neighborMap.values());
|
||||
if (neighborsToPopulate.length > 0) {
|
||||
const neighborIds = neighborsToPopulate.map((n) => n.id);
|
||||
const placeholders = neighborIds.map(() => "?").join(",");
|
||||
const entitiesRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT entry_id, entity_id FROM ledger_involved_entities
|
||||
WHERE entry_id IN (${placeholders})
|
||||
`
|
||||
)
|
||||
.all(...neighborIds) as { entry_id: string; entity_id: string }[];
|
||||
|
||||
const entitiesMap = new Map<string, string[]>();
|
||||
for (const er of entitiesRows) {
|
||||
if (!entitiesMap.has(er.entry_id)) {
|
||||
entitiesMap.set(er.entry_id, []);
|
||||
}
|
||||
entitiesMap.get(er.entry_id)!.push(er.entity_id);
|
||||
}
|
||||
|
||||
for (const n of neighborsToPopulate) {
|
||||
n.involvedEntityIds = entitiesMap.get(n.id) || [];
|
||||
finalEntries.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chronologically ASC for the final prompt output
|
||||
finalEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
|
||||
return finalEntries;
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare(`DELETE FROM ledger_entries WHERE id = ?`).run(id);
|
||||
}
|
||||
}
|
||||
|
||||
function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length || a.length === 0) return 0;
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
@@ -121,4 +121,108 @@ describe("LedgerRepository", () => {
|
||||
expect(ids).toContain("mem_social"); // due to involvedEntityIds
|
||||
expect(ids).not.toContain("mem_irrelevant");
|
||||
});
|
||||
|
||||
it("should retrieve ranked memories with recency, importance, and semantic match", () => {
|
||||
const now = new Date("2024-01-10T12:00:00.000Z");
|
||||
|
||||
repo.save({
|
||||
id: "mem1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-01T12:00:00.000Z",
|
||||
locationId: "loc1",
|
||||
involvedEntityIds: [],
|
||||
content: "Alice fought a dragon.",
|
||||
quotes: [],
|
||||
importance: 10,
|
||||
embedding: [0, 1, 0],
|
||||
});
|
||||
|
||||
repo.save({
|
||||
id: "mem2",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-10T11:00:00.000Z",
|
||||
locationId: "loc1",
|
||||
involvedEntityIds: [],
|
||||
content: "Alice ate a sandwich.",
|
||||
quotes: [],
|
||||
importance: 2,
|
||||
embedding: [1, 0, 0],
|
||||
});
|
||||
|
||||
repo.save({
|
||||
id: "mem3",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-10T11:50:00.000Z",
|
||||
locationId: "loc1",
|
||||
involvedEntityIds: [],
|
||||
content: "Alice read a book.",
|
||||
quotes: [],
|
||||
importance: 5,
|
||||
embedding: [0.707, 0.707, 0],
|
||||
});
|
||||
|
||||
// Query: [1, 0, 0]
|
||||
// mem3 score: recency (~0.998) + importance (0.5) + relevance (0.707) = ~2.205
|
||||
// mem2 score: recency (~0.99) + importance (0.2) + relevance (1.0) = ~2.19
|
||||
// mem1 score: recency (~0.114) + importance (1.0) + relevance (0.0) = ~1.114
|
||||
// If limit = 2, should return mem2 and mem3, sorted chronologically (mem2 first, then mem3)
|
||||
const results = repo.retrieve("alice", "loc1", [], [1, 0, 0], now, 2);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].id).toBe("mem2");
|
||||
expect(results[1].id).toBe("mem3");
|
||||
});
|
||||
|
||||
it("should pull in associative neighbors when specified", () => {
|
||||
repo.save({
|
||||
id: "mem_preceding",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-10T10:00:00.000Z",
|
||||
locationId: "loc_other",
|
||||
involvedEntityIds: [],
|
||||
content: "Alice woke up.",
|
||||
quotes: [],
|
||||
importance: 2,
|
||||
embedding: [],
|
||||
});
|
||||
|
||||
repo.save({
|
||||
id: "mem_target",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-10T11:00:00.000Z",
|
||||
locationId: "loc1",
|
||||
involvedEntityIds: [],
|
||||
content: "Alice arrived at tavern.",
|
||||
quotes: [],
|
||||
importance: 2,
|
||||
embedding: [],
|
||||
});
|
||||
|
||||
repo.save({
|
||||
id: "mem_succeeding",
|
||||
ownerId: "alice",
|
||||
timestamp: "2024-01-10T12:00:00.000Z",
|
||||
locationId: "loc_other",
|
||||
involvedEntityIds: [],
|
||||
content: "Alice ordered ale.",
|
||||
quotes: [],
|
||||
importance: 2,
|
||||
embedding: [],
|
||||
});
|
||||
|
||||
// Without neighbors: only returns mem_target
|
||||
const withoutNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
|
||||
includeAssociativeNeighbors: false,
|
||||
});
|
||||
expect(withoutNeighbors).toHaveLength(1);
|
||||
expect(withoutNeighbors[0].id).toBe("mem_target");
|
||||
|
||||
// With neighbors: returns preceding, target, and succeeding sorted chronologically
|
||||
const withNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
|
||||
includeAssociativeNeighbors: true,
|
||||
});
|
||||
expect(withNeighbors).toHaveLength(3);
|
||||
expect(withNeighbors[0].id).toBe("mem_preceding");
|
||||
expect(withNeighbors[1].id).toBe("mem_target");
|
||||
expect(withNeighbors[2].id).toBe("mem_succeeding");
|
||||
});
|
||||
});
|
||||
|
||||
115
web/docs/src/content/docs/architecture/memory-tier2.md
Normal file
115
web/docs/src/content/docs/architecture/memory-tier2.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Tier 2 Memory (Ledger)
|
||||
description: Long-term episodic memory storage and retrieval
|
||||
---
|
||||
|
||||
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 (or long-term memory) stores historical events that happened to the entity in the past. It acts as an episodic ledger.
|
||||
|
||||
```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
|
||||
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
### 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`).
|
||||
|
||||
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:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ledger_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
location_id TEXT,
|
||||
content TEXT NOT NULL,
|
||||
quotes_json TEXT,
|
||||
importance INTEGER NOT NULL,
|
||||
embedding BLOB,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_involved_entities (
|
||||
entry_id TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_id, entity_id),
|
||||
FOREIGN KEY (entry_id) REFERENCES ledger_entries(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_owner ON ledger_entries(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_location ON ledger_entries(location_id);
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
*Note: The automated handoff pipeline is currently deferred for future implementation.*
|
||||
|
||||
### Retrieval Architecture
|
||||
|
||||
Retrieval happens in phases to manage context window limits without running expensive vector searches across an entity's entire lifetime of memories.
|
||||
|
||||
#### Phase 1: Deterministic Heuristic Filtering
|
||||
|
||||
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:
|
||||
|
||||
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 2: Semantic & Episodic Ranking
|
||||
|
||||
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).
|
||||
|
||||
### Retrieval Triggers & Active Focus
|
||||
|
||||
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 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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
```text
|
||||
=== RECENT EVENTS ===
|
||||
Moments ago
|
||||
- you spoke to Strider: "Hello there"
|
||||
|
||||
=== YOUR MEMORIES ===
|
||||
A couple days ago
|
||||
- You met a hooded figure named Strider at The Prancing Pony.
|
||||
Quote: "I can avoid being seen, if I wish, but to disappear entirely, that is a rare gift."
|
||||
```
|
||||
Reference in New Issue
Block a user