mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
MAJOR(memory): Implement handoff protocol for buffer to memory conversion
This commit is contained in:
@@ -122,7 +122,7 @@ Space is a graph: `world → region → location → point of interest`, connect
|
||||
### Memory Tiers
|
||||
|
||||
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Vector Archive (planned):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Vector Archive (implemented):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Dossier (planned):** Each observer's subjective beliefs about another character.
|
||||
|
||||
Memory is per-character on purpose: recall is testimony from a vantage point, which is what makes interrogating two witnesses interesting.
|
||||
|
||||
@@ -459,6 +459,7 @@ export default function ConfigPage() {
|
||||
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules.", type: "generative" },
|
||||
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC).", type: "generative" },
|
||||
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock.", type: "generative" },
|
||||
{ key: "handoff", label: "Memory Handoff Engine", desc: "Promotes entities' working memories to the long-term Ledger via LLM summarization and pruning.", type: "generative" },
|
||||
{ key: "embeddings", label: "Text Embeddings Generator", desc: "Generates vector embeddings for long-term memory retrieval.", type: "embedding" },
|
||||
].map((task) => (
|
||||
<div
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const c of envCandidates) {
|
||||
}
|
||||
}
|
||||
|
||||
import { BufferRepository, LedgerRepository } from "@omnia/memory";
|
||||
import { BufferRepository, LedgerRepository, HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
|
||||
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
|
||||
import {
|
||||
ActorAgent,
|
||||
@@ -102,6 +102,7 @@ interface SimSession {
|
||||
validatorProvider: ILLMProvider;
|
||||
decoderProvider: ILLMProvider;
|
||||
timedeltaProvider: ILLMProvider;
|
||||
handoffProvider: ILLMProvider;
|
||||
embeddingProvider: IEmbeddingProvider;
|
||||
architect: Architect;
|
||||
aliasGenerator: AliasDeltaGenerator;
|
||||
@@ -268,6 +269,7 @@ class SimulationManager {
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
const architect = new Architect(
|
||||
@@ -294,6 +296,7 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
@@ -321,6 +324,7 @@ class SimulationManager {
|
||||
|
||||
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
|
||||
await this.runAliasResolution(session);
|
||||
await this.runHandoffResolution(session);
|
||||
session.aliasDoneForTurn = true;
|
||||
this.save(session);
|
||||
return this.snapshot(session);
|
||||
@@ -614,6 +618,31 @@ class SimulationManager {
|
||||
session.coreRepo.saveWorldState(worldState);
|
||||
}
|
||||
|
||||
private async runHandoffResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
);
|
||||
if (!worldState) throw new Error("World state lost");
|
||||
|
||||
const handoffEngine = new HandoffEngine(
|
||||
session.handoffProvider,
|
||||
session.embeddingProvider,
|
||||
session.bufferRepo,
|
||||
session.ledgerRepo,
|
||||
);
|
||||
|
||||
const entities = Array.from(worldState.entities.values());
|
||||
for (const entity of entities) {
|
||||
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
|
||||
const maxContext = session.handoffProvider.maxContext !== undefined ? session.handoffProvider.maxContext : 32768;
|
||||
|
||||
const trigger = checkHandoffTrigger(entity, bufferEntries, worldState.clock.get(), maxContext);
|
||||
if (trigger !== "none") {
|
||||
await handoffEngine.runHandoff(entity, bufferEntries, worldState.clock.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runAliasResolution(session: SimSession): Promise<void> {
|
||||
const worldState = session.coreRepo.loadWorldState(
|
||||
session.worldInstanceId,
|
||||
@@ -743,6 +772,7 @@ class SimulationManager {
|
||||
const validatorProvider = resolveProviderForTask("llm-validator");
|
||||
const decoderProvider = resolveProviderForTask("intent-decoder");
|
||||
const timedeltaProvider = resolveProviderForTask("timedelta");
|
||||
const handoffProvider = resolveProviderForTask("handoff");
|
||||
const embeddingProvider = resolveEmbeddingProvider();
|
||||
|
||||
const architect = new Architect(
|
||||
@@ -769,6 +799,7 @@ class SimulationManager {
|
||||
validatorProvider,
|
||||
decoderProvider,
|
||||
timedeltaProvider,
|
||||
handoffProvider,
|
||||
embeddingProvider,
|
||||
architect,
|
||||
aliasGenerator,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface LLMCallRecord {
|
||||
|
||||
export interface ILLMProvider {
|
||||
providerName: string;
|
||||
maxContext?: number;
|
||||
generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>>;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface BufferEntry {
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export { resolveAlias } from "@omnia/core";
|
||||
@@ -62,23 +63,31 @@ export class BufferRepository {
|
||||
location_id TEXT,
|
||||
intent_json TEXT NOT NULL,
|
||||
outcome_json TEXT,
|
||||
pinned INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
try {
|
||||
this.db.exec(`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
save(entry: BufferEntry): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned)
|
||||
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
|
||||
outcome_json = excluded.outcome_json,
|
||||
pinned = excluded.pinned
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
@@ -88,6 +97,7 @@ export class BufferRepository {
|
||||
entry.locationId,
|
||||
JSON.stringify(entry.intent),
|
||||
entry.outcome ? JSON.stringify(entry.outcome) : null,
|
||||
entry.pinned ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +105,7 @@ export class BufferRepository {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
|
||||
FROM buffer_entries WHERE id = ?
|
||||
`,
|
||||
)
|
||||
@@ -107,6 +117,7 @@ export class BufferRepository {
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
pinned?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -119,6 +130,7 @@ export class BufferRepository {
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
pinned: row.pinned === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,7 +138,7 @@ export class BufferRepository {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json, pinned
|
||||
FROM buffer_entries WHERE owner_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`,
|
||||
@@ -138,6 +150,7 @@ export class BufferRepository {
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
pinned?: number;
|
||||
}[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -147,6 +160,7 @@ export class BufferRepository {
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
pinned: row.pinned === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
300
packages/memory/src/handoff.ts
Normal file
300
packages/memory/src/handoff.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { z } from "zod";
|
||||
import { Entity, naturalizeTime } from "@omnia/core";
|
||||
import { BufferEntry, serializeSubjectiveBufferEntry, BufferRepository } from "./buffer.js";
|
||||
import { LedgerEntry, LedgerRepository } from "./ledger.js";
|
||||
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
|
||||
|
||||
export const HandoffChunkSchema = z.object({
|
||||
sourceEntryIds: z.array(z.string()), // buffer rows this chunk consumes
|
||||
content: z.string(), // third-person summary -> LedgerEntry.content
|
||||
quotes: z.array(z.string()), // verbatim, high-salience lines only
|
||||
importance: z.number().int().min(1).max(10),
|
||||
involvedEntityIds: z.array(z.string()),
|
||||
retainInBuffer: z.boolean(), // "pin"
|
||||
});
|
||||
|
||||
export const HandoffResultSchema = z.object({
|
||||
chunks: z.array(HandoffChunkSchema),
|
||||
});
|
||||
|
||||
export type HandoffResult = z.infer<typeof HandoffResultSchema>;
|
||||
export type HandoffTrigger = "none" | "voluntary" | "involuntary";
|
||||
|
||||
/**
|
||||
* Serializes the hypothetical memory section for size checking.
|
||||
*/
|
||||
export function getMemorySectionLength(
|
||||
entity: Entity,
|
||||
entries: BufferEntry[],
|
||||
now: Date,
|
||||
): number {
|
||||
if (entries.length === 0) {
|
||||
return `=== RECENT EVENTS ===\n(No recent events recorded.)`.length;
|
||||
}
|
||||
|
||||
const groupedLines: string[] = [];
|
||||
let currentGroup: string | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
const when = naturalizeTime(now, new Date(entry.timestamp));
|
||||
|
||||
if (when !== currentGroup) {
|
||||
currentGroup = when;
|
||||
const header = when.charAt(0).toUpperCase() + when.slice(1);
|
||||
groupedLines.push(header);
|
||||
}
|
||||
|
||||
groupedLines.push(` - ${serialized}`);
|
||||
}
|
||||
|
||||
return `=== RECENT EVENTS ===\n${groupedLines.join("\n")}`.length;
|
||||
}
|
||||
|
||||
function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
|
||||
if (bufferEntries.length === 0) return false;
|
||||
|
||||
// Find the location of the most recent buffer entries
|
||||
const lastEntry = bufferEntries[bufferEntries.length - 1];
|
||||
if (lastEntry.locationId && entity.locationId && lastEntry.locationId !== entity.locationId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if there are entries from different locations in the buffer
|
||||
const locations = new Set(bufferEntries.map(e => e.locationId).filter(loc => loc !== null));
|
||||
if (locations.size > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
|
||||
const N = 5; // N consecutive idle turns
|
||||
if (bufferEntries.length < N) return false;
|
||||
|
||||
// Check the last N entries
|
||||
const lastN = bufferEntries.slice(-N);
|
||||
return lastN.every(e => e.intent.type === "monologue");
|
||||
}
|
||||
|
||||
function checkAttributeTrigger(entity: Entity): boolean {
|
||||
const consciousness = entity.attributes.get("consciousness");
|
||||
if (consciousness && consciousness.getValue().toLowerCase() === "unconscious") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = entity.attributes.get("status");
|
||||
if (status && ["unconscious", "asleep", "dead", "inactive"].includes(status.getValue().toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks deterministically whether handoff should run for the given entity.
|
||||
*/
|
||||
export function checkHandoffTrigger(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
maxContext: number = 32768,
|
||||
): HandoffTrigger {
|
||||
if (bufferEntries.length === 0) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
// Involuntary triggers first (hard)
|
||||
if (maxContext > 0) {
|
||||
const memoryLength = getMemorySectionLength(entity, bufferEntries, now);
|
||||
const charCeiling = maxContext * 4 * 0.60;
|
||||
if (memoryLength > charCeiling) {
|
||||
return "involuntary";
|
||||
}
|
||||
}
|
||||
|
||||
// Event velocity
|
||||
if (bufferEntries.length > 20) {
|
||||
return "involuntary";
|
||||
}
|
||||
|
||||
// Voluntary triggers (soft)
|
||||
if (checkSceneExit(entity, bufferEntries)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
if (checkIdleDecay(bufferEntries)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
if (checkAttributeTrigger(entity)) {
|
||||
return "voluntary";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the buffer into candidate pool (older) and watermark tail (untouched).
|
||||
*/
|
||||
export function splitBufferForHandoff(
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
K: number = 8,
|
||||
): { candidates: BufferEntry[]; watermark: BufferEntry[] } {
|
||||
const sorted = [...bufferEntries].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
const freshBuckets = new Set([
|
||||
"just now",
|
||||
"moments ago",
|
||||
"a few minutes ago",
|
||||
"several minutes ago",
|
||||
]);
|
||||
|
||||
let watermarkStartIndex = sorted.length;
|
||||
|
||||
// 1. Mark last K entries as watermark
|
||||
if (sorted.length > K) {
|
||||
watermarkStartIndex = sorted.length - K;
|
||||
} else {
|
||||
watermarkStartIndex = 0;
|
||||
}
|
||||
|
||||
// 2. Expand watermark to include any fresh entries before it
|
||||
for (let i = watermarkStartIndex - 1; i >= 0; i--) {
|
||||
const bucket = naturalizeTime(now, new Date(sorted[i].timestamp));
|
||||
if (freshBuckets.has(bucket)) {
|
||||
watermarkStartIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
candidates: sorted.slice(0, watermarkStartIndex),
|
||||
watermark: sorted.slice(watermarkStartIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HandoffEngine processes memory handoffs using LLM summarization and DB transactions.
|
||||
*/
|
||||
export class HandoffEngine {
|
||||
constructor(
|
||||
private llmProvider: ILLMProvider,
|
||||
private embedProvider: IEmbeddingProvider,
|
||||
private bufferRepo: BufferRepository,
|
||||
private ledgerRepo: LedgerRepository,
|
||||
) {}
|
||||
|
||||
async runHandoff(
|
||||
entity: Entity,
|
||||
bufferEntries: BufferEntry[],
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const { candidates } = splitBufferForHandoff(bufferEntries, now);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatesList = candidates.map((entry) => {
|
||||
const serialized = serializeSubjectiveBufferEntry(entry, entity);
|
||||
return `ID: ${entry.id} | Timestamp: ${entry.timestamp} | Location: ${entry.locationId || "None"}\nContent: ${serialized}`;
|
||||
}).join("\n---\n");
|
||||
|
||||
const systemPrompt = `
|
||||
You are the memory Handoff Engine. Your task is to process a list of recent working memory buffer entries for an entity and select which memories to promote to the long-term Ledger, and which to forget or summarize.
|
||||
|
||||
Instructions:
|
||||
1. **Cluster** related consecutive buffer entries into high-level narrative beats or events (e.g. a full back-and-forth conversation or a single physical action and its outcome). Combine them into a single summary chunk.
|
||||
2. **Write in the third-person** for the "content" of each chunk (e.g. "John asked Mary for the key, and Mary reluctantly handed it over").
|
||||
3. **verbatim Quotes**: Extract verbatim, high-salience quotes from dialogue if relevant. Do not modify or invent quotes.
|
||||
4. **Determine Importance**: Assign an importance score from 1 (trivial, e.g. waking up) to 10 (life-altering, e.g. witnessing a crime).
|
||||
5. **Involved Entities**: Identify all entity IDs involved in the memories in this chunk.
|
||||
6. **Retain in Buffer (Pinning)**: If a beat represents an unresolved high-stakes situation (e.g. a standing threat, an unanswered accusation, an ongoing chase or conflict), set "retainInBuffer" to true so it remains in the working memory buffer for immediate context. Otherwise, set it to false so it is safely pruned from the buffer.
|
||||
7. **Exclude stage business**: Glances, sighs, ambient noticing, and irrelevant sensory details should be ignored and not included in any promoted chunk. They will be forgotten.
|
||||
8. **Forget by omission**: Any buffer entry ID that you do not include in any chunk's "sourceEntryIds" will be permanently deleted and forgotten.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
Subject Entity ID: ${entity.id}
|
||||
Current Time: ${now.toISOString()}
|
||||
|
||||
Working Memory Candidates for Handoff:
|
||||
${candidatesList}
|
||||
`.trim();
|
||||
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
schema: HandoffResultSchema,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
const db = (this.bufferRepo as any).db;
|
||||
|
||||
const ledgerEntries: LedgerEntry[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
let embedding: number[] = [];
|
||||
try {
|
||||
embedding = await this.embedProvider.embed(chunk.content);
|
||||
} catch (err) {
|
||||
console.error("Failed to generate embedding for handoff chunk:", err);
|
||||
return false;
|
||||
}
|
||||
|
||||
ledgerEntries.push({
|
||||
id: "ledger-" + Math.random().toString(36).substr(2, 9) + "-" + Date.now(),
|
||||
ownerId: entity.id,
|
||||
timestamp: now.toISOString(),
|
||||
locationId: entity.locationId,
|
||||
involvedEntityIds: chunk.involvedEntityIds,
|
||||
content: chunk.content,
|
||||
quotes: chunk.quotes,
|
||||
importance: chunk.importance,
|
||||
embedding,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// Save promoted ledger entries
|
||||
for (const entry of ledgerEntries) {
|
||||
this.ledgerRepo.save(entry);
|
||||
}
|
||||
|
||||
// Keep track of pinned source IDs
|
||||
const pinnedSourceIds = new Set<string>();
|
||||
for (const chunk of result.chunks) {
|
||||
if (chunk.retainInBuffer) {
|
||||
for (const id of chunk.sourceEntryIds) {
|
||||
pinnedSourceIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete or pin entries
|
||||
for (const candidate of candidates) {
|
||||
if (pinnedSourceIds.has(candidate.id)) {
|
||||
const updated = { ...candidate, pinned: true };
|
||||
this.bufferRepo.save(updated);
|
||||
} else {
|
||||
this.bufferRepo.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Transaction failed during handoff execution:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./buffer.js";
|
||||
export * from "./ledger.js";
|
||||
export * from "./handoff.js";
|
||||
|
||||
167
packages/memory/tests/handoff.test.ts
Normal file
167
packages/memory/tests/handoff.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { MockLLMProvider, MockEmbeddingProvider } from "@omnia/llm";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
LedgerRepository,
|
||||
checkHandoffTrigger,
|
||||
splitBufferForHandoff,
|
||||
HandoffEngine,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Memory Handoff Tests (Tier 1)", () => {
|
||||
const now = new Date("2026-07-07T12:00:00.000Z");
|
||||
|
||||
test("splitBufferForHandoff correctly splits based on watermark and fresh buckets", () => {
|
||||
const entries: BufferEntry[] = [];
|
||||
|
||||
// Add 12 older entries (older than 30 minutes)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const minutesAgo = 60 - i;
|
||||
const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000).toISOString();
|
||||
entries.push({
|
||||
id: `entry-old-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Old event ${i}`,
|
||||
description: `does old thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add 4 fresh entries (moments ago / just now)
|
||||
const freshTimes = [
|
||||
new Date(now.getTime() - 10 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 30 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 90 * 1000).toISOString(),
|
||||
new Date(now.getTime() - 180 * 1000).toISOString(),
|
||||
];
|
||||
|
||||
freshTimes.forEach((timestamp, idx) => {
|
||||
entries.push({
|
||||
id: `entry-fresh-${idx}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: `Fresh event ${idx}`,
|
||||
description: `does fresh thing ${idx}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { candidates, watermark } = splitBufferForHandoff(entries, now, 8);
|
||||
|
||||
expect(watermark.length).toBeGreaterThanOrEqual(8);
|
||||
expect(candidates.length).toBe(8);
|
||||
expect(candidates[0].id).toBe("entry-old-0");
|
||||
});
|
||||
|
||||
test("checkHandoffTrigger detects scene change and idle decay", () => {
|
||||
const entity = new Entity("alice");
|
||||
entity.locationId = "room-2";
|
||||
|
||||
// Scenario 1: Empty buffer -> no trigger
|
||||
expect(checkHandoffTrigger(entity, [], now)).toBe("none");
|
||||
|
||||
// Scenario 2: Scene exit
|
||||
const entryAtRoom1: BufferEntry = {
|
||||
id: "e-1",
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-1",
|
||||
intent: { type: "dialogue", originalText: "hello", description: "says hello", actorId: "alice", targetIds: [] },
|
||||
};
|
||||
expect(checkHandoffTrigger(entity, [entryAtRoom1], now)).toBe("voluntary");
|
||||
|
||||
// Scenario 3: Idle decay (5 consecutive monologues)
|
||||
const monologues: BufferEntry[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `m-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp: now.toISOString(),
|
||||
locationId: "room-2",
|
||||
intent: { type: "monologue", originalText: "think", description: "thinks", actorId: "alice", targetIds: [] },
|
||||
}));
|
||||
expect(checkHandoffTrigger(entity, monologues, now)).toBe("voluntary");
|
||||
});
|
||||
|
||||
test("HandoffEngine promotes candidates to Ledger and prunes buffer transactionally", async () => {
|
||||
const db = new Database(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO objects (id, type, name) VALUES ('alice', 'character', 'Alice');
|
||||
`);
|
||||
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
const ledgerRepo = new LedgerRepository(db);
|
||||
|
||||
const entity = new Entity("alice");
|
||||
entity.locationId = "room-1";
|
||||
|
||||
const entries: BufferEntry[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const timestamp = new Date(now.getTime() - (50 - i) * 60 * 1000).toISOString();
|
||||
const entry: BufferEntry = {
|
||||
id: `entry-${i}`,
|
||||
ownerId: "alice",
|
||||
timestamp,
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: i % 2 === 0 ? "dialogue" : "action",
|
||||
originalText: `Event ${i}`,
|
||||
description: `does thing ${i}`,
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
};
|
||||
bufferRepo.save(entry);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
const mockHandoffResult = {
|
||||
chunks: [
|
||||
{
|
||||
sourceEntryIds: ["entry-0"],
|
||||
content: "Alice initiated dialogue and performed various tasks.",
|
||||
quotes: ["Event 0"],
|
||||
importance: 5,
|
||||
involvedEntityIds: ["bob"],
|
||||
retainInBuffer: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const llmProvider = new MockLLMProvider([mockHandoffResult]);
|
||||
const embedProvider = new MockEmbeddingProvider();
|
||||
const engine = new HandoffEngine(llmProvider, embedProvider, bufferRepo, ledgerRepo);
|
||||
|
||||
const success = await engine.runHandoff(entity, entries, now);
|
||||
expect(success).toBe(true);
|
||||
|
||||
const ledgerRows = db.prepare("SELECT * FROM ledger_entries WHERE owner_id = ?").all("alice") as any[];
|
||||
expect(ledgerRows.length).toBe(1);
|
||||
expect(ledgerRows[0].content).toBe("Alice initiated dialogue and performed various tasks.");
|
||||
expect(JSON.parse(ledgerRows[0].quotes_json)).toEqual(["Event 0"]);
|
||||
expect(ledgerRows[0].importance).toBe(5);
|
||||
|
||||
const remainingBuffer = bufferRepo.listForOwner("alice");
|
||||
expect(remainingBuffer.length).toBe(8);
|
||||
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-0");
|
||||
expect(remainingBuffer.map((b) => b.id)).not.toContain("entry-1");
|
||||
expect(remainingBuffer[0].id).toBe("entry-2");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" }
|
||||
{ "path": "../intent" },
|
||||
{ "path": "../llm" }
|
||||
]
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -393,6 +393,9 @@ importers:
|
||||
'@omnia/intent':
|
||||
specifier: workspace:*
|
||||
version: link:../intent
|
||||
'@omnia/llm':
|
||||
specifier: workspace:*
|
||||
version: link:../llm
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
Reference in New Issue
Block a user