chore: Format files

This commit is contained in:
2026-07-15 19:20:13 +05:30
parent 9838d4ce59
commit 9d18444220
78 changed files with 9416 additions and 5242 deletions

View File

@@ -11,7 +11,7 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
beforeEach(() => {
db = new Database(":memory:");
// Core database schemas for testing
db.exec(`
CREATE TABLE objects (
@@ -32,8 +32,11 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
});
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 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");
@@ -85,7 +88,10 @@ describe("ActorPromptBuilder with Long-Term Memory Integration", () => {
});
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 world = new WorldState(
"world-123",
new Date("2024-01-10T12:00:00.000Z"),
);
const alice = new Entity("alice", "tavern");
world.addEntity(alice);

View File

@@ -13,7 +13,8 @@ export class Architect {
private timeDeltaGenerator: TimeDeltaGenerator;
constructor(
llmProvider: ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
llmProvider:
ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
private repo?: SQLiteRepository,
) {
let valProv: ILLMProvider;
@@ -60,8 +61,12 @@ export class Architect {
if (intent.type === "monologue") {
return {
isValid: true,
reason: "Monologue intent bypasses validation (internal thought, not perceivable).",
timeDelta: { minutesToAdvance: 0, explanation: "Internal thought no time elapsed." },
reason:
"Monologue intent bypasses validation (internal thought, not perceivable).",
timeDelta: {
minutesToAdvance: 0,
explanation: "Internal thought — no time elapsed.",
},
};
}

View File

@@ -1,4 +1,3 @@
export * from "./llm-validator.js";
export * from "./architect.js";
export * from "./delta.js";

View File

@@ -28,7 +28,8 @@ export class LLMValidator {
if (intent.type === "monologue") {
return {
isValid: true,
reason: "Monologue intents are internal thoughts and bypass validation.",
reason:
"Monologue intents are internal thoughts and bypass validation.",
};
}

View File

@@ -1,6 +1,11 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core";
import {
WorldState,
Entity,
SQLiteRepository,
AttributeVisibility,
} from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import { Intent } from "@omnia/intent";
@@ -96,7 +101,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
const db = new Database(":memory:");
const repo = new SQLiteRepository(db);
const world = new WorldState("world-xyz", new Date("2026-07-06T12:00:00.000Z"));
const world = new WorldState(
"world-xyz",
new Date("2026-07-06T12:00:00.000Z"),
);
const alice = new Entity("alice");
world.addEntity(alice);
@@ -106,8 +114,14 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
// Setup mock LLM responses:
// First call: validateIntent (ValidationResult)
// Second call: TimeDeltaGenerator (TimeDelta)
const mockValidation = { isValid: true, reason: "Alice has the lockpick kit and skill." };
const mockTimeDelta = { minutesToAdvance: 20, explanation: "Picking a lock takes time." };
const mockValidation = {
isValid: true,
reason: "Alice has the lockpick kit and skill.",
};
const mockTimeDelta = {
minutesToAdvance: 20,
explanation: "Picking a lock takes time.",
};
const llmProvider = new MockLLMProvider([mockValidation, mockTimeDelta]);
const architect = new Architect(llmProvider, repo);
@@ -131,7 +145,9 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
expect(result.timeDelta!.minutesToAdvance).toBe(20);
// Verify clock was advanced locally
const expectedTime = new Date(new Date("2026-07-06T12:00:00.000Z").getTime() + 20 * 60_000);
const expectedTime = new Date(
new Date("2026-07-06T12:00:00.000Z").getTime() + 20 * 60_000,
);
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
// Verify it was persisted to the database
@@ -151,7 +167,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
world.addEntity(bob);
repo.saveWorldState(world);
const mockValidation = { isValid: false, reason: "Bob is bound by chains." };
const mockValidation = {
isValid: false,
reason: "Bob is bound by chains.",
};
const llmProvider = new MockLLMProvider([mockValidation]); // TimeDeltaGenerator shouldn't be called
const architect = new Architect(llmProvider, repo);
@@ -188,8 +207,16 @@ describe("AliasDeltaGenerator Unit Tests (Tier 1)", () => {
const world = new WorldState("world-1");
const viewer = new Entity("viewer-1");
const target = new Entity("target-1");
target.addAttribute("appearance", "A tall elf with silver hair", AttributeVisibility.PUBLIC);
target.addAttribute("clothing", "A green tunic", AttributeVisibility.PUBLIC);
target.addAttribute(
"appearance",
"A tall elf with silver hair",
AttributeVisibility.PUBLIC,
);
target.addAttribute(
"clothing",
"A green tunic",
AttributeVisibility.PUBLIC,
);
world.addEntity(viewer);
world.addEntity(target);

View File

@@ -85,7 +85,7 @@ export function naturalizeTime(now: Date, past: Date): string {
const nowHour = now.getHours();
const pastIsWaking = pastHour >= 5 && pastHour < 22;
const nowIsWaking = nowHour >= 5 && nowHour < 22;
const isSameSubjectiveDay = pastIsWaking && nowIsWaking && deltaHours < 18;
if (isSameSubjectiveDay) {

View File

@@ -10,4 +10,3 @@ export * from "./world.js";
export * from "./clock.js";
export * from "./repository.js";
export * from "./alias.js";

View File

@@ -1,4 +1,8 @@
import { AttributableObject, Attribute, serializeAttributes } from "./attribute.js";
import {
AttributableObject,
Attribute,
serializeAttributes,
} from "./attribute.js";
import { Entity } from "./entity.js";
import { WorldClock } from "./clock.js";
import { resolveAlias } from "./alias.js";
@@ -54,8 +58,15 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
// Serialize world attributes
if (worldState.attributes.size > 0) {
lines.push("World Attributes:");
const worldAttrsStr = serializeAttributes(Array.from(worldState.attributes.values()));
lines.push(worldAttrsStr.split("\n").map(l => " " + l).join("\n"));
const worldAttrsStr = serializeAttributes(
Array.from(worldState.attributes.values()),
);
lines.push(
worldAttrsStr
.split("\n")
.map((l) => " " + l)
.join("\n"),
);
}
// Serialize locations and their attributes/portals
@@ -63,33 +74,46 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
if (worldState.locations.size > 0) {
for (const loc of worldState.locations.values()) {
lines.push(` - Location [ID: ${loc.id}]:`);
const parentId = (loc as { parentId?: string | null }).parentId;
if (parentId) {
lines.push(` * Parent Location ID: ${parentId}`);
}
if (loc.attributes.size > 0) {
const locAttrsStr = serializeAttributes(Array.from(loc.attributes.values()));
lines.push(locAttrsStr.split("\n").map(l => " " + l).join("\n"));
const locAttrsStr = serializeAttributes(
Array.from(loc.attributes.values()),
);
lines.push(
locAttrsStr
.split("\n")
.map((l) => " " + l)
.join("\n"),
);
} else {
lines.push(" * (No attributes)");
}
const connections = (loc as { connections?: unknown[] }).connections as {
targetId: string;
portalName?: string;
portalStateDescriptor?: string;
visionProp: number;
soundProp: number;
bidirectional: boolean;
}[] | undefined;
const connections = (loc as { connections?: unknown[] }).connections as
| {
targetId: string;
portalName?: string;
portalStateDescriptor?: string;
visionProp: number;
soundProp: number;
bidirectional: boolean;
}[]
| undefined;
if (connections && connections.length > 0) {
lines.push(" * Connections:");
for (const conn of connections) {
const portalStr = conn.portalName ? ` via ${conn.portalName} (${conn.portalStateDescriptor || "normal"})` : "";
lines.push(` -> To: ${conn.targetId}${portalStr} (Vision: ${conn.visionProp}, Sound: ${conn.soundProp})`);
const portalStr = conn.portalName
? ` via ${conn.portalName} (${conn.portalStateDescriptor || "normal"})`
: "";
lines.push(
` -> To: ${conn.targetId}${portalStr} (Vision: ${conn.visionProp}, Sound: ${conn.soundProp})`,
);
}
}
}
@@ -106,8 +130,15 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
lines.push(` * Location ID: ${entity.locationId}`);
}
if (entity.attributes.size > 0) {
const entityAttrsStr = serializeAttributes(Array.from(entity.attributes.values()));
lines.push(entityAttrsStr.split("\n").map(l => " " + l).join("\n"));
const entityAttrsStr = serializeAttributes(
Array.from(entity.attributes.values()),
);
lines.push(
entityAttrsStr
.split("\n")
.map((l) => " " + l)
.join("\n"),
);
} else {
lines.push(" * (No attributes)");
}
@@ -119,7 +150,6 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
return lines.join("\n");
}
/**
* Serializes a single attribute the way a viewer perceives it — name and
* value only, no visibility/ACL metadata (the viewer already sees only
@@ -158,13 +188,23 @@ export function serializeSubjectiveWorldState(
const worldVisible = worldState.getVisibleAttributesFor(viewerId);
if (worldVisible.length > 0) {
lines.push("World (as you know it):");
lines.push(serializeVisibleAttributes(worldVisible).split("\n").map((l) => " " + l).join("\n"));
lines.push(
serializeVisibleAttributes(worldVisible)
.split("\n")
.map((l) => " " + l)
.join("\n"),
);
}
// --- Self ---
lines.push(`Self (${viewerAlias}):`);
const selfVisible = viewer.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(selfVisible).split("\n").map((l) => " " + l).join("\n"));
lines.push(
serializeVisibleAttributes(selfVisible)
.split("\n")
.map((l) => " " + l)
.join("\n"),
);
// --- Location / perceived entities ---
lines.push("What you perceive around you:");
@@ -175,7 +215,12 @@ export function serializeSubjectiveWorldState(
const locVisible = location.getVisibleAttributesFor(viewerId);
if (locVisible.length > 0) {
lines.push(" Location attributes:");
lines.push(serializeVisibleAttributes(locVisible).split("\n").map((l) => " " + l).join("\n"));
lines.push(
serializeVisibleAttributes(locVisible)
.split("\n")
.map((l) => " " + l)
.join("\n"),
);
}
}
} else {
@@ -199,7 +244,12 @@ export function serializeSubjectiveWorldState(
const alias = resolveAlias(viewer, e.id);
lines.push(` - ${alias}:`);
const eVisible = e.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
lines.push(
serializeVisibleAttributes(eVisible)
.split("\n")
.map((l) => " " + l)
.join("\n"),
);
}
} else {
lines.push(" You are alone here.");

View File

@@ -11,23 +11,39 @@ describe("TimeNaturalization Unit Tests (Tier 1)", () => {
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");
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");
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");
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");
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");
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");
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");
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");
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)", () => {
@@ -35,12 +51,16 @@ describe("TimeNaturalization Unit Tests (Tier 1)", () => {
// 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");
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");
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
@@ -52,45 +72,73 @@ describe("TimeNaturalization Unit Tests (Tier 1)", () => {
// 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");
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");
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");
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");
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");
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");
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");
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");
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");
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");
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");
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");
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");
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");
expect(naturalizeTime(now, new Date(2023, 6, 1, 12, 0, 0))).toBe(
"years ago",
);
});
});

View File

@@ -25,7 +25,11 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
const llm = new MockLLMProvider([mockResponse]);
const decoder = new IntentDecoder(llm);
const result = await decoder.decode(world, "alice", "Alice opened the chest.");
const result = await decoder.decode(
world,
"alice",
"Alice opened the chest.",
);
expect(result.intents).toHaveLength(1);
expect(result.intents[0].type).toBe("action");

View File

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

View File

@@ -78,7 +78,8 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
{
id: "openrouter",
displayName: "OpenRouter",
description: "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
description:
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
defaultModel: "google/gemini-2.5-flash",
defaultEmbeddingModel: "openai/text-embedding-3-small",
},

View File

@@ -43,8 +43,9 @@ function getSettingsDb() {
dbPath = path.join(dbDir, "settings.db");
}
const db = new Database(dbPath);
db.prepare(`
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -54,22 +55,29 @@ function getSettingsDb() {
modelName TEXT,
type TEXT NOT NULL DEFAULT 'generative'
)
`).run();
`,
).run();
try {
db.prepare(`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`).run();
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN modelName TEXT`,
).run();
} catch {
// ignore
}
try {
db.prepare(`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`).run();
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`,
).run();
} catch {
// ignore
}
try {
db.prepare(`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`).run();
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`,
).run();
} catch {
// ignore
}
@@ -77,7 +85,9 @@ function getSettingsDb() {
// Auto-bootstrap environment variables if DB contains 0 instances
try {
if (!hasBootstrapped) {
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
const totalCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
.get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
@@ -85,26 +95,59 @@ function getSettingsDb() {
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(`
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
`,
).run(
id,
"Gemini (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-2.5-flash",
"generative",
32768,
);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(`
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
`,
).run(
embedId,
"Gemini Embed (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-embedding-001",
"embedding",
0,
);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(`
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
`,
).run(
id,
"OpenRouter (Env)",
"openrouter",
openRouterKey.trim(),
isActive,
"google/gemini-2.5-flash",
"generative",
32768,
);
}
}
hasBootstrapped = true;
@@ -112,7 +155,7 @@ function getSettingsDb() {
} catch {
// ignore write lock issues or other DB errors during bootstrap
}
return db;
}
@@ -138,7 +181,12 @@ export class ProviderManager {
isActive: r.isActive === 1,
modelName: r.modelName || undefined,
type: (r.type as "generative" | "embedding") || "generative",
maxContext: r.maxContext !== undefined && r.maxContext !== null ? r.maxContext : (r.type === "embedding" ? 0 : 32768),
maxContext:
r.maxContext !== undefined && r.maxContext !== null
? r.maxContext
: r.type === "embedding"
? 0
: 32768,
}));
} finally {
db.close();
@@ -151,24 +199,51 @@ export class ProviderManager {
apiKey: string,
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number
maxContext?: number,
): ModelProviderInstance {
const db = getSettingsDb();
try {
const id = "provider-" + Date.now();
const activeCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`)
.prepare(
`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`,
)
.get(type) as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
db.prepare(`
const actualMaxContext =
maxContext !== undefined
? maxContext
: type === "generative"
? 32768
: 0;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, providerName, apiKey, isActive, modelName || null, type, actualMaxContext);
return { id, name, providerName, apiKey, isActive: isActive === 1, modelName, type, maxContext: actualMaxContext };
`,
).run(
id,
name,
providerName,
apiKey,
isActive,
modelName || null,
type,
actualMaxContext,
);
return {
id,
name,
providerName,
apiKey,
isActive: isActive === 1,
modelName,
type,
maxContext: actualMaxContext,
};
} finally {
db.close();
}
@@ -177,15 +252,19 @@ export class ProviderManager {
static delete(id: string): void {
const db = getSettingsDb();
try {
const provider = db.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`).get(id) as { isActive: number; type: string } | undefined;
const provider = db
.prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`)
.get(id) as { isActive: number; type: string } | undefined;
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
if (provider && provider.isActive === 1) {
const next = db
.prepare(`SELECT id FROM provider_instances WHERE type = ? LIMIT 1`)
.get(provider.type) as { id: string } | undefined;
if (next) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(next.id);
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(next.id);
}
}
} finally {
@@ -196,10 +275,16 @@ export class ProviderManager {
static setActive(id: string): void {
const db = getSettingsDb();
try {
const target = db.prepare(`SELECT type FROM provider_instances WHERE id = ?`).get(id) as { type: string } | undefined;
const target = db
.prepare(`SELECT type FROM provider_instances WHERE id = ?`)
.get(id) as { type: string } | undefined;
if (target) {
db.prepare(`UPDATE provider_instances SET isActive = 0 WHERE type = ?`).run(target.type);
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(id);
db.prepare(
`UPDATE provider_instances SET isActive = 0 WHERE type = ?`,
).run(target.type);
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(id);
}
} finally {
db.close();
@@ -213,75 +298,64 @@ export class ProviderManager {
apiKey?: string,
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number
maxContext?: number,
): void {
const db = getSettingsDb();
try {
const actualMaxContext = maxContext !== undefined ? maxContext : (type === "generative" ? 32768 : 0);
const actualMaxContext =
maxContext !== undefined
? maxContext
: type === "generative"
? 32768
: 0;
if (apiKey && apiKey.trim()) {
db.prepare(`
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`).run(name, providerName, apiKey, modelName || null, type, actualMaxContext, id);
`,
).run(
name,
providerName,
apiKey,
modelName || null,
type,
actualMaxContext,
id,
);
} else {
db.prepare(`
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
WHERE id = ?
`).run(name, providerName, modelName || null, type, actualMaxContext, id);
`,
).run(
name,
providerName,
modelName || null,
type,
actualMaxContext,
id,
);
}
} finally {
db.close();
}
}
static getActive(type: "generative" | "embedding" = "generative"): ModelProviderInstance | null {
static getActive(
type: "generative" | "embedding" = "generative",
): ModelProviderInstance | null {
const db = getSettingsDb();
try {
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
} | undefined;
if (!row) {
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash", "generative", 32768);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(embedId, "Gemini Embed (Env)", "google-genai", googleKey.trim(), 1, "gemini-embedding-001", "embedding", 0);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash", "generative", 32768);
}
const retryRow = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`).get(type) as {
const row = db
.prepare(
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
)
.get(type) as
| {
id: string;
name: string;
providerName: string;
@@ -290,7 +364,91 @@ export class ProviderManager {
modelName?: string;
type: string;
maxContext?: number;
} | undefined;
}
| undefined;
if (!row) {
const totalCount = db
.prepare(`SELECT COUNT(*) as count FROM provider_instances`)
.get() as { count: number };
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
let hasInsertedGenerative = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"Gemini (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-2.5-flash",
"generative",
32768,
);
hasInsertedGenerative = true;
const embedId = "provider-default-google-embed";
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
embedId,
"Gemini Embed (Env)",
"google-genai",
googleKey.trim(),
1,
"gemini-embedding-001",
"embedding",
0,
);
}
if (openRouterKey && openRouterKey.trim()) {
const id = "provider-default-openrouter";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"OpenRouter (Env)",
"openrouter",
openRouterKey.trim(),
isActive,
"google/gemini-2.5-flash",
"generative",
32768,
);
}
const retryRow = db
.prepare(
`SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`,
)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
if (retryRow) {
return {
@@ -301,24 +459,36 @@ export class ProviderManager {
isActive: true,
modelName: retryRow.modelName || undefined,
type: retryRow.type as "generative" | "embedding",
maxContext: retryRow.maxContext !== undefined && retryRow.maxContext !== null ? retryRow.maxContext : (retryRow.type === "embedding" ? 0 : 32768),
maxContext:
retryRow.maxContext !== undefined &&
retryRow.maxContext !== null
? retryRow.maxContext
: retryRow.type === "embedding"
? 0
: 32768,
};
}
}
// If there's no active row but some rows exist, return the first one as active, or update it
const firstRow = db.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`).get(type) as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
} | undefined;
const firstRow = db
.prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`)
.get(type) as
| {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
modelName?: string;
type: string;
maxContext?: number;
}
| undefined;
if (firstRow) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(firstRow.id);
db.prepare(
`UPDATE provider_instances SET isActive = 1 WHERE id = ?`,
).run(firstRow.id);
return {
id: firstRow.id,
name: firstRow.name,
@@ -327,7 +497,12 @@ export class ProviderManager {
isActive: true,
modelName: firstRow.modelName || undefined,
type: firstRow.type as "generative" | "embedding",
maxContext: firstRow.maxContext !== undefined && firstRow.maxContext !== null ? firstRow.maxContext : (firstRow.type === "embedding" ? 0 : 32768),
maxContext:
firstRow.maxContext !== undefined && firstRow.maxContext !== null
? firstRow.maxContext
: firstRow.type === "embedding"
? 0
: 32768,
};
}
return null;
@@ -341,7 +516,12 @@ export class ProviderManager {
isActive: true,
modelName: row.modelName || undefined,
type: (row.type as "generative" | "embedding") || "generative",
maxContext: row.maxContext !== undefined && row.maxContext !== null ? row.maxContext : (row.type === "embedding" ? 0 : 32768),
maxContext:
row.maxContext !== undefined && row.maxContext !== null
? row.maxContext
: row.type === "embedding"
? 0
: 32768,
};
} catch {
const googleKey = process.env.GOOGLE_API_KEY;
@@ -396,12 +576,14 @@ export class ProviderManager {
static getMappings(): Record<string, string> {
const db = getSettingsDb();
try {
db.prepare(`
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`).run();
`,
).run();
const rows = db.prepare(`SELECT * FROM provider_mappings`).all() as {
task: string;
providerInstanceId: string;
@@ -419,20 +601,24 @@ export class ProviderManager {
static setMapping(task: string, providerInstanceId: string): void {
const db = getSettingsDb();
try {
db.prepare(`
db.prepare(
`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`).run();
`,
).run();
if (!providerInstanceId) {
db.prepare(`DELETE FROM provider_mappings WHERE task = ?`).run(task);
} else {
db.prepare(`
db.prepare(
`
INSERT INTO provider_mappings (task, providerInstanceId)
VALUES (?, ?)
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId
`).run(task, providerInstanceId);
`,
).run(task, providerInstanceId);
}
} finally {
db.close();

View File

@@ -1,13 +1,23 @@
import { z } from "zod";
import { ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings } from "@langchain/google-genai";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
import {
ChatGoogleGenerativeAI,
GoogleGenerativeAIEmbeddings,
} from "@langchain/google-genai";
import {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
IEmbeddingProvider,
} from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class GeminiProvider implements ILLMProvider {
static readonly providerId = "google-genai";
static readonly displayName = "Google Gemini";
static readonly description = "Official Gemini integration using Google Gen AI SDK";
static readonly description =
"Official Gemini integration using Google Gen AI SDK";
static readonly defaultModel = "gemini-2.5-flash";
providerName = "Gemini";
@@ -17,7 +27,12 @@ export class GeminiProvider implements ILLMProvider {
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
@@ -47,7 +62,9 @@ export class GeminiProvider implements ILLMProvider {
}
if (!key) {
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
throw new Error(
"GOOGLE_API_KEY is required to initialize GeminiProvider",
);
}
this.modelNameUsed = model || "gemini-2.5-flash";
@@ -60,7 +77,9 @@ export class GeminiProvider implements ILLMProvider {
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
const structuredModel = this.model.withStructuredOutput(request.schema, {
includeRaw: true,
});
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
@@ -84,7 +103,8 @@ export class GeminiProvider implements ILLMProvider {
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
maxContext:
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({
@@ -123,7 +143,9 @@ export class GeminiEmbeddingProvider implements IEmbeddingProvider {
}
if (!key) {
throw new Error("GOOGLE_API_KEY is required to initialize GeminiEmbeddingProvider");
throw new Error(
"GOOGLE_API_KEY is required to initialize GeminiEmbeddingProvider",
);
}
this.model = new GoogleGenerativeAIEmbeddings({

View File

@@ -1,10 +1,17 @@
import { z } from "zod";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord, IEmbeddingProvider } from "../llm.js";
import {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
IEmbeddingProvider,
} from "../llm.js";
export class MockLLMProvider implements ILLMProvider {
static readonly providerId = "mock";
static readonly displayName = "Mock LLM Provider";
static readonly description = "Stateless mock provider for testing and offline development";
static readonly description =
"Stateless mock provider for testing and offline development";
static readonly defaultModel = "mock";
providerName = "mock";
@@ -30,7 +37,10 @@ export class MockLLMProvider implements ILLMProvider {
const parsed = request.schema.parse(next);
return { success: true, data: parsed, usage };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
return {
success: false,
error: e instanceof Error ? e.message : String(e),
};
}
}
}

View File

@@ -1,13 +1,19 @@
import { z } from "zod";
import { ChatOpenRouter } from "@langchain/openrouter";
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
import {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
} from "../llm.js";
import { llmConfig } from "../config.js";
import { ProviderManager } from "../provider-manager.js";
export class OpenRouterProvider implements ILLMProvider {
static readonly providerId = "openrouter";
static readonly displayName = "OpenRouter";
static readonly description = "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
static readonly description =
"Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
static readonly defaultModel = "google/gemini-2.5-flash";
providerName = "OpenRouter";
@@ -17,7 +23,12 @@ export class OpenRouterProvider implements ILLMProvider {
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string, maxContext?: number) {
constructor(
apiKey?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
let key = apiKey;
let model = modelName;
this.providerInstanceName = providerInstanceName;
@@ -47,7 +58,9 @@ export class OpenRouterProvider implements ILLMProvider {
}
if (!key) {
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
throw new Error(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
);
}
this.modelNameUsed = model || "google/gemini-2.5-flash";
@@ -60,7 +73,9 @@ export class OpenRouterProvider implements ILLMProvider {
async generateStructuredResponse<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
const structuredModel = this.model.withStructuredOutput(request.schema, {
includeRaw: true,
});
const result = (await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
@@ -84,7 +99,8 @@ export class OpenRouterProvider implements ILLMProvider {
totalTokens: raw?.usage_metadata?.total_tokens || 0,
modelName: this.modelNameUsed,
providerInstanceName: this.providerInstanceName || "Default",
maxContext: this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
maxContext:
this.maxContextUsed !== undefined ? this.maxContextUsed : 32768,
};
this.lastCalls.push({

View File

@@ -72,7 +72,7 @@ describe("MockEmbeddingProvider Unit Tests (Tier 1)", () => {
expect(vec1.length).toBe(768);
expect(vec2.length).toBe(768);
expect(vec1).toEqual(vec2); // Deterministic
// Ensure values are numbers between -1.0 and 1.0 (since they are generated with Math.sin)
expect(typeof vec1[0]).toBe("number");
expect(vec1[0]).toBeGreaterThanOrEqual(-1.0);

View File

@@ -45,7 +45,7 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
// Save current config
const originalKey = llmConfig.OPENROUTER_API_KEY;
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
try {
const provider = new OpenRouterProvider();
expect(provider.providerName).toBe("OpenRouter");
@@ -61,7 +61,7 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
try {
expect(() => new OpenRouterProvider()).toThrow(
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider"
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider",
);
} finally {
llmConfig.OPENROUTER_API_KEY = originalKey;

View File

@@ -1,7 +1,11 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import { ProviderManager, setDbPathOverride, resetHasBootstrapped } from "../src/index.js";
import {
ProviderManager,
setDbPathOverride,
resetHasBootstrapped,
} from "../src/index.js";
describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
let tempDbPath: string;
@@ -17,7 +21,10 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
resetHasBootstrapped();
// Generate a unique temp database path for this test run
tempDbPath = path.resolve(process.cwd(), `test-settings-${Date.now()}-${Math.random().toString(36).substring(2)}.db`);
tempDbPath = path.resolve(
process.cwd(),
`test-settings-${Date.now()}-${Math.random().toString(36).substring(2)}.db`,
);
setDbPathOverride(tempDbPath);
});
@@ -76,8 +83,14 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
expect(bootstrapped.isActive).toBe(true);
// Edit name and key
ProviderManager.update(bootstrapped.id, "My Gemini Key", "google-genai", "new-secret-key", "gemini-2.5-pro");
ProviderManager.update(
bootstrapped.id,
"My Gemini Key",
"google-genai",
"new-secret-key",
"gemini-2.5-pro",
);
const listAfterUpdate = ProviderManager.list();
expect(listAfterUpdate.length).toBe(2);
const updated = listAfterUpdate.find((p) => p.id === bootstrapped.id);

View File

@@ -26,7 +26,11 @@ export function serializeSubjectiveBufferEntry(
const isSelf = viewer.id === entry.intent.actorId;
if (isSelf) {
let details = (entry.intent.selfDescription || entry.intent.description || entry.intent.originalText).trim();
let details = (
entry.intent.selfDescription ||
entry.intent.description ||
entry.intent.originalText
).trim();
if (details.length > 0) {
details = details.charAt(0).toUpperCase() + details.slice(1);
}
@@ -69,7 +73,9 @@ export class BufferRepository {
`);
try {
this.db.exec(`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`);
this.db.exec(
`ALTER TABLE buffer_entries ADD COLUMN pinned INTEGER DEFAULT 0;`,
);
} catch {
// ignore
}

View File

@@ -1,6 +1,10 @@
import { z } from "zod";
import { Entity, naturalizeTime } from "@omnia/core";
import { BufferEntry, serializeSubjectiveBufferEntry, BufferRepository } from "./buffer.js";
import {
BufferEntry,
serializeSubjectiveBufferEntry,
BufferRepository,
} from "./buffer.js";
import { LedgerEntry, LedgerRepository } from "./ledger.js";
import { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
@@ -56,12 +60,18 @@ function checkSceneExit(entity: Entity, bufferEntries: BufferEntry[]): boolean {
// Find the location of the most recent buffer entries
const lastEntry = bufferEntries[bufferEntries.length - 1];
if (lastEntry.locationId && entity.locationId && lastEntry.locationId !== entity.locationId) {
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));
const locations = new Set(
bufferEntries.map((e) => e.locationId).filter((loc) => loc !== null),
);
if (locations.size > 1) {
return true;
}
@@ -75,17 +85,25 @@ function checkIdleDecay(bufferEntries: BufferEntry[]): boolean {
// Check the last N entries
const lastN = bufferEntries.slice(-N);
return lastN.every(e => e.intent.type === "monologue");
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") {
if (
consciousness &&
consciousness.getValue().toLowerCase() === "unconscious"
) {
return true;
}
const status = entity.attributes.get("status");
if (status && ["unconscious", "asleep", "dead", "inactive"].includes(status.getValue().toLowerCase())) {
if (
status &&
["unconscious", "asleep", "dead", "inactive"].includes(
status.getValue().toLowerCase(),
)
) {
return true;
}
@@ -108,7 +126,7 @@ export function checkHandoffTrigger(
// Involuntary triggers first (hard)
if (maxContext > 0) {
const memoryLength = getMemorySectionLength(entity, bufferEntries, now);
const charCeiling = maxContext * 4 * 0.60;
const charCeiling = maxContext * 4 * 0.6;
if (memoryLength > charCeiling) {
return "involuntary";
}
@@ -201,10 +219,12 @@ export class HandoffEngine {
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 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.
@@ -239,7 +259,11 @@ ${candidatesList}
}
const result = response.data;
const db = (this.bufferRepo as unknown as { db: { transaction: (fn: () => void) => void } }).db;
const db = (
this.bufferRepo as unknown as {
db: { transaction: (fn: () => void) => void };
}
).db;
const ledgerEntries: LedgerEntry[] = [];
for (const chunk of result.chunks) {
@@ -252,7 +276,11 @@ ${candidatesList}
}
ledgerEntries.push({
id: "ledger-" + Math.random().toString(36).substr(2, 9) + "-" + Date.now(),
id:
"ledger-" +
Math.random().toString(36).substr(2, 9) +
"-" +
Date.now(),
ownerId: entity.id,
timestamp: now.toISOString(),
locationId: entity.locationId,

View File

@@ -82,7 +82,7 @@ export class LedgerRepository {
entry.importance,
entry.embedding.length > 0
? Buffer.from(new Float32Array(entry.embedding).buffer)
: null
: null,
);
deleteEntities.run(entry.id);
@@ -92,14 +92,18 @@ export class LedgerRepository {
})();
}
private mapRowToEntry(row: Record<string, unknown>, involvedEntityIds: string[]): LedgerEntry {
private mapRowToEntry(
row: Record<string, unknown>,
involvedEntityIds: string[],
): LedgerEntry {
const embedding: number[] = row.embedding
? Array.from(
new Float32Array(
(row.embedding as Buffer).buffer,
(row.embedding as Buffer).byteOffset,
(row.embedding as Buffer).byteLength / Float32Array.BYTES_PER_ELEMENT
)
(row.embedding as Buffer).byteLength /
Float32Array.BYTES_PER_ELEMENT,
),
)
: [];
@@ -123,7 +127,7 @@ export class LedgerRepository {
SELECT id, owner_id, timestamp, location_id, content, quotes_json, importance, embedding
FROM ledger_entries
WHERE id = ?
`
`,
)
.get(id) as Record<string, unknown> | undefined;
@@ -133,11 +137,14 @@ export class LedgerRepository {
.prepare(
`
SELECT entity_id FROM ledger_involved_entities WHERE entry_id = ?
`
`,
)
.all(id) as { entity_id: string }[];
return this.mapRowToEntry(row, entitiesRows.map((er) => er.entity_id));
return this.mapRowToEntry(
row,
entitiesRows.map((er) => er.entity_id),
);
}
/**
@@ -151,7 +158,7 @@ export class LedgerRepository {
ownerId: string,
currentLocationId: string | null,
currentInvolvedEntityIds: string[],
limit: number = 20
limit: number = 20,
): LedgerEntry[] {
let query = `
SELECT DISTINCT le.id, le.owner_id, le.timestamp, le.location_id, le.content, le.quotes_json, le.importance, le.embedding
@@ -182,7 +189,10 @@ export class LedgerRepository {
`;
params.push(limit);
const rows = this.db.prepare(query).all(...params) as Record<string, unknown>[];
const rows = this.db.prepare(query).all(...params) as Record<
string,
unknown
>[];
if (rows.length === 0) return [];
@@ -193,7 +203,7 @@ export class LedgerRepository {
`
SELECT entry_id, entity_id FROM ledger_involved_entities
WHERE entry_id IN (${placeholders})
`
`,
)
.all(...entryIds) as { entry_id: string; entity_id: string }[];
@@ -205,7 +215,9 @@ export class LedgerRepository {
entitiesMap.get(er.entry_id)!.push(er.entity_id);
}
return rows.map((row) => this.mapRowToEntry(row, entitiesMap.get(row.id as string) || []));
return rows.map((row) =>
this.mapRowToEntry(row, entitiesMap.get(row.id as string) || []),
);
}
private fetchRawNeighbors(ownerId: string, timestamp: string): LedgerEntry[] {
@@ -220,7 +232,7 @@ export class LedgerRepository {
WHERE owner_id = ? AND timestamp < ?
ORDER BY timestamp DESC
LIMIT 1
`
`,
)
.get(ownerId, timestamp) as Record<string, unknown> | undefined;
@@ -237,7 +249,7 @@ export class LedgerRepository {
WHERE owner_id = ? AND timestamp > ?
ORDER BY timestamp ASC
LIMIT 1
`
`,
)
.get(ownerId, timestamp) as Record<string, unknown> | undefined;
@@ -269,16 +281,22 @@ export class LedgerRepository {
importanceWeight?: number;
relevanceWeight?: number;
decayRate?: number;
}
},
): LedgerEntry[] {
const includeAssociativeNeighbors = options?.includeAssociativeNeighbors ?? false;
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);
const candidates = this.getRelevant(
ownerId,
currentLocationId,
currentInvolvedEntityIds,
100,
);
if (candidates.length === 0) return [];
// Score candidates
@@ -318,7 +336,10 @@ export class LedgerRepository {
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)) {
if (
!finalEntries.some((fe) => fe.id === rn.id) &&
!neighborMap.has(rn.id)
) {
neighborMap.set(rn.id, rn);
}
}
@@ -333,7 +354,7 @@ export class LedgerRepository {
`
SELECT entry_id, entity_id FROM ledger_involved_entities
WHERE entry_id IN (${placeholders})
`
`,
)
.all(...neighborIds) as { entry_id: string; entity_id: string }[];
@@ -353,7 +374,10 @@ export class LedgerRepository {
}
// Sort chronologically ASC for the final prompt output
finalEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
finalEntries.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
return finalEntries;
}

View File

@@ -20,7 +20,9 @@ describe("Memory Handoff Tests (Tier 1)", () => {
// 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();
const timestamp = new Date(
now.getTime() - minutesAgo * 60 * 1000,
).toISOString();
entries.push({
id: `entry-old-${i}`,
ownerId: "alice",
@@ -80,7 +82,13 @@ describe("Memory Handoff Tests (Tier 1)", () => {
ownerId: "alice",
timestamp: now.toISOString(),
locationId: "room-1",
intent: { type: "dialogue", originalText: "hello", description: "says hello", actorId: "alice", targetIds: [] },
intent: {
type: "dialogue",
originalText: "hello",
description: "says hello",
actorId: "alice",
targetIds: [],
},
};
expect(checkHandoffTrigger(entity, [entryAtRoom1], now)).toBe("voluntary");
@@ -90,7 +98,13 @@ describe("Memory Handoff Tests (Tier 1)", () => {
ownerId: "alice",
timestamp: now.toISOString(),
locationId: "room-2",
intent: { type: "monologue", originalText: "think", description: "thinks", actorId: "alice", targetIds: [] },
intent: {
type: "monologue",
originalText: "think",
description: "thinks",
actorId: "alice",
targetIds: [],
},
}));
expect(checkHandoffTrigger(entity, monologues, now)).toBe("voluntary");
});
@@ -114,7 +128,9 @@ describe("Memory Handoff Tests (Tier 1)", () => {
const entries: BufferEntry[] = [];
for (let i = 0; i < 10; i++) {
const timestamp = new Date(now.getTime() - (50 - i) * 60 * 1000).toISOString();
const timestamp = new Date(
now.getTime() - (50 - i) * 60 * 1000,
).toISOString();
const entry: BufferEntry = {
id: `entry-${i}`,
ownerId: "alice",
@@ -147,14 +163,23 @@ describe("Memory Handoff Tests (Tier 1)", () => {
const llmProvider = new MockLLMProvider([mockHandoffResult]);
const embedProvider = new MockEmbeddingProvider();
const engine = new HandoffEngine(llmProvider, embedProvider, bufferRepo, ledgerRepo);
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 Record<string, unknown>[];
const ledgerRows = db
.prepare("SELECT * FROM ledger_entries WHERE owner_id = ?")
.all("alice") as Record<string, unknown>[];
expect(ledgerRows.length).toBe(1);
expect(ledgerRows[0].content).toBe("Alice initiated dialogue and performed various tasks.");
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);

View File

@@ -8,7 +8,7 @@ describe("LedgerRepository", () => {
beforeEach(() => {
db = new Database(":memory:");
// We need to create a dummy objects table to satisfy foreign keys
db.exec(`
CREATE TABLE objects (
@@ -51,7 +51,7 @@ describe("LedgerRepository", () => {
expect(loaded?.content).toBe(entry.content);
expect(loaded?.quotes).toEqual(entry.quotes);
expect(loaded?.importance).toBe(5);
// Check float precision
expect(loaded?.embedding[0]).toBeCloseTo(0.1);
expect(loaded?.embedding[1]).toBeCloseTo(0.2);
@@ -113,7 +113,7 @@ describe("LedgerRepository", () => {
});
const relevant = repo.getRelevant("alice", "loc1", ["bob"]);
expect(relevant).toHaveLength(3);
const ids = relevant.map((r) => r.id);
expect(ids).toContain("mem_high_salience"); // due to importance >= 8
@@ -210,16 +210,32 @@ describe("LedgerRepository", () => {
});
// Without neighbors: only returns mem_target
const withoutNeighbors = repo.retrieve("alice", "loc1", [], undefined, new Date("2024-01-10T14:00:00.000Z"), 1, {
includeAssociativeNeighbors: false,
});
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,
});
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");

View File

@@ -41,7 +41,9 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe("The hooded figure says, 'Hello there' to the bartender");
expect(result).toBe(
"The hooded figure says, 'Hello there' to the bartender",
);
});
test("serializes action intent with outcome details", () => {
@@ -69,7 +71,9 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
};
const result = serializeSubjectiveBufferEntry(entry, viewer);
expect(result).toBe('The hooded figure attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
expect(result).toBe(
"The hooded figure attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)",
);
});
test("serializes self-reference and unfamiliar actors", () => {
@@ -110,7 +114,10 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
},
};
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
const resultUnfamiliar = serializeSubjectiveBufferEntry(
entryUnfamiliar,
viewer,
);
expect(resultUnfamiliar).toBe("An unfamiliar figure knocks on the door");
});
});
@@ -118,7 +125,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
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);

View File

@@ -12,17 +12,13 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
description: "A spooky old manor.",
startTime: "2026-07-09T08:00:00.000Z",
world: {
attributes: [
{ name: "weather", value: "stormy", visibility: "PUBLIC" },
],
attributes: [{ name: "weather", value: "stormy", visibility: "PUBLIC" }],
},
locations: [
{
id: "lobby",
parentId: null,
attributes: [
{ name: "light", value: "dim", visibility: "PUBLIC" },
],
attributes: [{ name: "light", value: "dim", visibility: "PUBLIC" }],
connections: [
{
targetId: "kitchen",
@@ -43,7 +39,12 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
id: "investigator",
locationId: "lobby",
attributes: [
{ name: "sanity", value: "100", visibility: "PRIVATE", allowedEntities: ["investigator"] },
{
name: "sanity",
value: "100",
visibility: "PRIVATE",
allowedEntities: ["investigator"],
},
],
aliases: {
ghost: "shadowy specter",
@@ -98,11 +99,16 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
expect(world).not.toBeNull();
expect(world!.id).toBe(targetWorldId);
expect(world!.clock.get().toISOString()).toBe("2026-07-09T08:00:00.000Z");
expect(world!.attributes.get("name")?.getValue()).toBe("Haunted House Mystery");
expect(world!.attributes.get("name")?.getValue()).toBe(
"Haunted House Mystery",
);
expect(world!.attributes.get("weather")?.getValue()).toBe("stormy");
// 2. Verify Locations loaded with connections & hierarchy
const locations = coreRepo.listLocations(targetWorldId, (id, parentId) => new Location(id, parentId));
const locations = coreRepo.listLocations(
targetWorldId,
(id, parentId) => new Location(id, parentId),
);
expect(locations).toHaveLength(2);
const lobby = locations.find((l) => l.id === "lobby");
@@ -122,7 +128,9 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
const loadedInvestigator = world!.getEntity("investigator");
expect(loadedInvestigator).toBeDefined();
expect(loadedInvestigator!.locationId).toBe("lobby");
expect(loadedInvestigator!.attributes.get("sanity")?.getValue()).toBe("100");
expect(loadedInvestigator!.attributes.get("sanity")?.getValue()).toBe(
"100",
);
expect(loadedInvestigator!.aliases.get("ghost")).toBe("shadowy specter");
// 4. Verify pre-seeded memories loaded in BufferRepository

View File

@@ -10,7 +10,10 @@ import { ScenarioLoader, ScenarioSchema } from "../src/index.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCENARIO_PATH = path.resolve(__dirname, "../../../content/demo/scenarios/talking-room.json");
const SCENARIO_PATH = path.resolve(
__dirname,
"../../../content/demo/scenarios/talking-room.json",
);
describe("Talking Room Demo Scenario Test (Tier 1)", () => {
test("talking-room.json exists, parses, and loads correctly into database", async () => {
@@ -37,17 +40,30 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
expect(world).not.toBeNull();
expect(world!.attributes.get("name")?.getValue()).toBe("Talking Room");
expect(world!.attributes.get("name")?.visibility).toBe("PRIVATE");
expect(world!.attributes.get("description")?.getValue()).toBe(scenarioJson.description);
expect(world!.attributes.get("description")?.getValue()).toBe(
scenarioJson.description,
);
expect(world!.attributes.get("description")?.visibility).toBe("PRIVATE");
expect(world!.attributes.get("experiment_codename")?.getValue()).toBe("Project Tabula Rasa (Phase 3)");
expect(world!.attributes.get("experiment_codename")?.visibility).toBe("PRIVATE");
expect(world!.attributes.get("experiment_codename")?.getAllowedEntities()).toHaveLength(0); // System only!
expect(world!.attributes.get("experiment_codename")?.getValue()).toBe(
"Project Tabula Rasa (Phase 3)",
);
expect(world!.attributes.get("experiment_codename")?.visibility).toBe(
"PRIVATE",
);
expect(
world!.attributes.get("experiment_codename")?.getAllowedEntities(),
).toHaveLength(0); // System only!
// 5. Assert location
const locations = coreRepo.listLocations(worldInstanceId, (id, parentId) => new Location(id, parentId));
const locations = coreRepo.listLocations(
worldInstanceId,
(id, parentId) => new Location(id, parentId),
);
expect(locations).toHaveLength(1);
expect(locations[0].id).toBe("white-room");
expect(locations[0].attributes.get("description")?.getValue()).toContain("A pristine, featureless room");
expect(locations[0].attributes.get("description")?.getValue()).toContain(
"A pristine, featureless room",
);
// 6. Assert entities and their private attributes / allowedEntities
const alphaId = "7c9b83b3-8cfb-4e89-8d77-626a5757d591";
@@ -56,14 +72,14 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
const alpha = world!.getEntity(alphaId);
expect(alpha).toBeDefined();
expect(alpha!.locationId).toBe("white-room");
// Name visibility check
const alphaName = alpha!.attributes.get("name")!;
expect(alphaName.getValue()).toBe("Bob");
expect(alphaName.visibility).toBe("PRIVATE");
expect(alphaName.hasAccess(alphaId)).toBe(true);
expect(alphaName.hasAccess(betaId)).toBe(false);
// Check system-only attribute (neural_erasure_dose)
const alphaDose = alpha!.attributes.get("neural_erasure_dose")!;
expect(alphaDose.visibility).toBe("PRIVATE");
@@ -81,7 +97,9 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
// Verify subjective aliases can be dynamically resolved via AliasDeltaGenerator
const { AliasDeltaGenerator } = await import("@omnia/architect");
const { MockLLMProvider } = await import("@omnia/llm");
const llmProvider = new MockLLMProvider([{ alias: "the person in the Beta jumpsuit" }]);
const llmProvider = new MockLLMProvider([
{ alias: "the person in the Beta jumpsuit" },
]);
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
const generatedAlias = await aliasGenerator.generate(alpha!, beta!);
@@ -94,7 +112,9 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
const subjectiveState = serializeSubjectiveWorldState(world!, alphaId);
expect(subjectiveState).toContain("You are at location: white-room");
expect(subjectiveState).toContain("Location attributes:");
expect(subjectiveState).toContain("description: A pristine, featureless room");
expect(subjectiveState).toContain(
"description: A pristine, featureless room",
);
expect(subjectiveState).toContain("lighting: Bright, uniform illumination");
// Verify objective world state serializes locations (physics awareness)
@@ -102,7 +122,9 @@ describe("Talking Room Demo Scenario Test (Tier 1)", () => {
const objectiveState = serializeObjectiveWorldState(world!);
expect(objectiveState).toContain("Locations:");
expect(objectiveState).toContain("- Location [ID: white-room]:");
expect(objectiveState).toContain("description: A pristine, featureless room");
expect(objectiveState).toContain(
"description: A pristine, featureless room",
);
// 7. Assert initial pre-seeded memories
const alphaMemories = bufferRepo.listForOwner(alphaId);