feat: replace identitydb with supermemory
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
import { llm } from "@/openrouter";
|
||||
import { extractedFactSchema, type ExtractedFactResult } from "@/openrouter/schema";
|
||||
import { LlmFactExtractor } from "identitydb";
|
||||
|
||||
export const factExtractor = new LlmFactExtractor({
|
||||
model: {
|
||||
async generateText({ instruction, input }) {
|
||||
const result = await llm.call<ExtractedFactResult>(llm.models.identity, {
|
||||
instruction,
|
||||
message: input,
|
||||
jsonSchemaName: "fact-extractor",
|
||||
jsonSchema: extractedFactSchema,
|
||||
});
|
||||
return result.items;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { IdentityDB, type Space } from "identitydb";
|
||||
import type { Space } from "./types";
|
||||
|
||||
const llmCalls: Array<{ model: unknown; options: any }> = [];
|
||||
let customMonthlyDays: Array<{ day: number; summary: string }> | null = null;
|
||||
@@ -24,11 +24,6 @@ let customAvailability: Array<{
|
||||
status: string;
|
||||
}> | null = null;
|
||||
|
||||
/**
|
||||
* Queue of LLM responses for tool-calling flows (sendMessage). Each entry is
|
||||
* returned in order. Shape matches OpenRouter's `ChatResult.choices[0]`
|
||||
* reduced form: `{ content, tool_calls, finish_reason }`.
|
||||
*/
|
||||
type ToolCallResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -136,6 +131,9 @@ const mockCall = mock(async <T>(model: unknown, options: any): Promise<T> => {
|
||||
},
|
||||
} as unknown as T;
|
||||
}
|
||||
if (typeof options.message === "string" || options.message === undefined) {
|
||||
return "test-description" as unknown as T;
|
||||
}
|
||||
throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`);
|
||||
});
|
||||
|
||||
@@ -150,14 +148,191 @@ mock.module("@/openrouter", () => ({
|
||||
mock.module("@/config", () => ({
|
||||
config: {
|
||||
openrouterApiKey: "test-key",
|
||||
dbPath: ":memory:",
|
||||
supermemoryApiKey: "test-supermemory-key",
|
||||
braindbPath: "/tmp/brainbox-test-braindb.json",
|
||||
},
|
||||
}));
|
||||
|
||||
interface StoredDoc {
|
||||
id: string;
|
||||
customId: string | null;
|
||||
containerTag: string;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
class MockSupermemory {
|
||||
docs = new Map<string, StoredDoc>();
|
||||
private nextId = 0;
|
||||
documentsAddCalls = 0;
|
||||
|
||||
constructor(_options: { apiKey: string }) {}
|
||||
|
||||
documents = {
|
||||
add: async (params: {
|
||||
content: string;
|
||||
containerTag: string;
|
||||
customId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => {
|
||||
this.documentsAddCalls += 1;
|
||||
const id = `mock-${++this.nextId}`;
|
||||
const stored: StoredDoc = {
|
||||
id,
|
||||
customId: params.customId ?? null,
|
||||
containerTag: params.containerTag,
|
||||
content: params.content,
|
||||
summary: null,
|
||||
metadata: params.metadata ?? null,
|
||||
};
|
||||
this.docs.set(id, stored);
|
||||
return { id, status: "done" };
|
||||
},
|
||||
list: async (params: {
|
||||
containerTags?: Array<string>;
|
||||
limit?: number;
|
||||
}) => {
|
||||
const tags = params.containerTags ?? [];
|
||||
const limit = params.limit ?? 200;
|
||||
const all = Array.from(this.docs.values()).filter((d) =>
|
||||
tags.length === 0 ? true : tags.includes(d.containerTag),
|
||||
);
|
||||
const memories = all.slice(0, limit).map((d) => ({
|
||||
id: d.id,
|
||||
customId: d.customId,
|
||||
containerTag: d.containerTag,
|
||||
summary: d.summary,
|
||||
metadata: d.metadata as
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Record<string, unknown>
|
||||
| Array<unknown>
|
||||
| null,
|
||||
content: d.content,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
status: "done" as const,
|
||||
type: "text" as const,
|
||||
connectionId: null,
|
||||
filepath: null,
|
||||
title: null,
|
||||
}));
|
||||
return {
|
||||
memories,
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
totalItems: memories.length,
|
||||
totalPages: 1,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const d = this.docs.get(id);
|
||||
if (!d) {
|
||||
throw new Error(`MockSupermemory.documents.get: no such id ${id}`);
|
||||
}
|
||||
return {
|
||||
id: d.id,
|
||||
customId: d.customId,
|
||||
containerTag: d.containerTag,
|
||||
content: d.content,
|
||||
summary: d.summary,
|
||||
metadata: d.metadata as
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Record<string, unknown>
|
||||
| Array<unknown>
|
||||
| null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
status: "done" as const,
|
||||
type: "text" as const,
|
||||
connectionId: null,
|
||||
filepath: null,
|
||||
title: null,
|
||||
source: null,
|
||||
ogImage: null,
|
||||
raw: null,
|
||||
spatialPoint: null,
|
||||
taskType: "memory" as const,
|
||||
url: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
search = {
|
||||
execute: async (params: {
|
||||
q: string;
|
||||
containerTag?: string;
|
||||
limit?: number;
|
||||
onlyMatchingChunks?: boolean;
|
||||
}) => {
|
||||
const q = params.q.toLowerCase();
|
||||
const limit = params.limit ?? 5;
|
||||
const hits = Array.from(this.docs.values())
|
||||
.filter(
|
||||
(d) =>
|
||||
(params.containerTag
|
||||
? d.containerTag === params.containerTag
|
||||
: true) && d.content.toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((d, i) => ({
|
||||
chunks: [
|
||||
{
|
||||
content: d.content,
|
||||
isRelevant: true,
|
||||
score: 1 - i * 0.1,
|
||||
},
|
||||
],
|
||||
summary: d.summary,
|
||||
score: 1 - i * 0.1,
|
||||
documentId: d.id,
|
||||
metadata: (d.metadata as Record<string, unknown>) ?? null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
title: d.customId,
|
||||
type: "text" as const,
|
||||
}));
|
||||
return {
|
||||
results: hits,
|
||||
total: hits.length,
|
||||
timing: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
findByCustomId(customId: string): StoredDoc | undefined {
|
||||
for (const d of this.docs.values()) {
|
||||
if (d.customId === customId) return d;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.docs.clear();
|
||||
this.nextId = 0;
|
||||
this.documentsAddCalls = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the real `supermemory` SDK with our in-memory mock. The
|
||||
* static factories `Brain.create` / `Brain.createDebug` / `Brain.load`
|
||||
* all do `new Supermemory({ apiKey })` internally; this mock is what
|
||||
* they pick up.
|
||||
*/
|
||||
mock.module("supermemory", () => ({
|
||||
default: MockSupermemory,
|
||||
}));
|
||||
|
||||
const { Brain } = await import("./index");
|
||||
const { brainManager } = await import("./manager");
|
||||
const { formatDateKey, nextDay, nextMonth } = await import("./schedule");
|
||||
const { formatDateKey, nextMonth } = await import("./schedule");
|
||||
type BrainItem = import("./manager").BrainItem;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -168,16 +343,10 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {});
|
||||
|
||||
async function makeBrain(
|
||||
embeddingProvider: unknown = NOOP_EMBEDDING_PROVIDER,
|
||||
): Promise<InstanceType<typeof Brain>> {
|
||||
const db = await IdentityDB.connect({
|
||||
client: "sqlite",
|
||||
filename: ":memory:",
|
||||
});
|
||||
await db.initialize();
|
||||
async function makeBrain(): Promise<InstanceType<typeof Brain>> {
|
||||
const db = new MockSupermemory({ apiKey: "test-supermemory-key" });
|
||||
const spaceName = `test-space-${randomUUID()}`;
|
||||
const space: Space = await db.upsertSpace({ name: spaceName });
|
||||
const space: Space = { name: spaceName, description: "Test Brain space" };
|
||||
const brainbase: BrainItem = {
|
||||
brainId: randomUUID(),
|
||||
spaceName,
|
||||
@@ -185,7 +354,7 @@ async function makeBrain(
|
||||
baseSystemPrompt:
|
||||
"Test personality: night owl, introverted, studies at midnight.",
|
||||
};
|
||||
return new Brain(db, space, brainbase, false, embeddingProvider as never);
|
||||
return new Brain(db as never, space, brainbase, false);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -197,10 +366,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("Brain.createDailySchedule", () => {
|
||||
test("S1: returns 48 slots in 30-min intervals and persists a fact", async () => {
|
||||
test("S1: returns 48 slots in 30-min intervals and persists a document", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const today = new Date(2026, 5, 5);
|
||||
const expectedTomorrow = nextDay(today);
|
||||
const expectedTomorrow = (await import("./schedule")).nextDay(today);
|
||||
const expectedKey = formatDateKey(expectedTomorrow);
|
||||
|
||||
const result = await brain.createDailySchedule(today, "focus on writing");
|
||||
@@ -228,50 +398,39 @@ describe("Brain.createDailySchedule", () => {
|
||||
expect(llmCall!.options.message).toContain("focus on writing");
|
||||
expect(llmCall!.options.message).toContain("Test personality");
|
||||
|
||||
const facts = await brain.db.getTopicFacts(
|
||||
`daily-schedule:${expectedKey}`,
|
||||
{
|
||||
spaceName: brain.space.name,
|
||||
},
|
||||
);
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(JSON.parse(facts[0]!.statement).items).toHaveLength(48);
|
||||
const stored = db.findByCustomId(`daily-schedule:${expectedKey}`);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.containerTag).toBe(brain.space.name);
|
||||
expect(JSON.parse(stored!.content).items).toHaveLength(48);
|
||||
});
|
||||
|
||||
test("S4: month wrap (June 30 -> July 1)", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const today = new Date(2026, 5, 30);
|
||||
const expectedKey = formatDateKey(new Date(2026, 6, 1));
|
||||
|
||||
await brain.createDailySchedule(today, "");
|
||||
|
||||
const facts = await brain.db.getTopicFacts(
|
||||
`daily-schedule:${expectedKey}`,
|
||||
{
|
||||
spaceName: brain.space.name,
|
||||
},
|
||||
);
|
||||
expect(facts).toHaveLength(1);
|
||||
const stored = db.findByCustomId(`daily-schedule:${expectedKey}`);
|
||||
expect(stored).toBeDefined();
|
||||
});
|
||||
|
||||
test("S4b: year wrap (December 31 -> January 1 next year)", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const today = new Date(2026, 11, 31);
|
||||
const expectedKey = "2027-01-01";
|
||||
|
||||
await brain.createDailySchedule(today, "");
|
||||
|
||||
const facts = await brain.db.getTopicFacts(
|
||||
`daily-schedule:${expectedKey}`,
|
||||
{
|
||||
spaceName: brain.space.name,
|
||||
},
|
||||
);
|
||||
expect(facts).toHaveLength(1);
|
||||
const stored = db.findByCustomId(`daily-schedule:${expectedKey}`);
|
||||
expect(stored).toBeDefined();
|
||||
});
|
||||
|
||||
test("S6: consumes monthly summary for the target day when present", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
|
||||
customMonthlyDays = Array.from({ length: 30 }, (_, i) => ({
|
||||
day: i + 1,
|
||||
@@ -282,13 +441,8 @@ describe("Brain.createDailySchedule", () => {
|
||||
const todayForMonthly = new Date(2026, 4, 15);
|
||||
await brain.createMonthlySchedule(todayForMonthly, "");
|
||||
|
||||
const monthlyFacts = await brain.db.getTopicFacts(
|
||||
`monthly-schedule:2026-06`,
|
||||
{
|
||||
spaceName: brain.space.name,
|
||||
},
|
||||
);
|
||||
expect(monthlyFacts).toHaveLength(1);
|
||||
const monthlyStored = db.findByCustomId("monthly-schedule:2026-06");
|
||||
expect(monthlyStored).toBeDefined();
|
||||
|
||||
llmCalls.length = 0;
|
||||
customDailySlots = build48Slots();
|
||||
@@ -304,11 +458,63 @@ describe("Brain.createDailySchedule", () => {
|
||||
"UNIQUE_SUMMARY_FOR_DAY_10",
|
||||
);
|
||||
});
|
||||
|
||||
test("S9: injects 2-days-ago schedule as recent context when one exists", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
|
||||
const twoDaysAgoTarget = new Date(2026, 5, 7);
|
||||
const twoDaysAgoTomorrow = (await import("./schedule")).nextDay(
|
||||
twoDaysAgoTarget,
|
||||
);
|
||||
const twoDaysAgoKey = formatDateKey(twoDaysAgoTomorrow);
|
||||
|
||||
await brain.add({
|
||||
customId: `daily-schedule:${twoDaysAgoKey}`,
|
||||
content: JSON.stringify({
|
||||
items: Array.from({ length: 48 }, (_, i) => ({
|
||||
start: `${String(Math.floor(i / 2)).padStart(2, "0")}:${String((i % 2) * 30).padStart(2, "0")}`,
|
||||
end: `${String(Math.floor((i + 1) / 2)).padStart(2, "0")}:${String(((i + 1) % 2) * 30).padStart(2, "0")}`,
|
||||
activity: `prior-day-activity-${i}`,
|
||||
notes: "",
|
||||
})),
|
||||
}),
|
||||
metadata: { kind: "schedule", source: "createDailySchedule", date: twoDaysAgoKey },
|
||||
});
|
||||
|
||||
llmCalls.length = 0;
|
||||
const today = new Date(2026, 5, 9);
|
||||
await brain.createDailySchedule(today, "");
|
||||
|
||||
const dailyLlmCall = llmCalls.find(
|
||||
(c) => c.options.jsonSchemaName === "daily-schedule",
|
||||
);
|
||||
expect(dailyLlmCall).toBeDefined();
|
||||
expect(dailyLlmCall!.options.message).toContain(
|
||||
`Recent schedule (${twoDaysAgoKey}, 2 days ago):`,
|
||||
);
|
||||
expect(dailyLlmCall!.options.message).toContain("prior-day-activity-0");
|
||||
});
|
||||
|
||||
test("S10: 2-days-ago context says 'no schedule on file' when prior day is missing", async () => {
|
||||
const brain = await makeBrain();
|
||||
const today = new Date(2026, 5, 9);
|
||||
await brain.createDailySchedule(today, "");
|
||||
|
||||
const dailyLlmCall = llmCalls.find(
|
||||
(c) => c.options.jsonSchemaName === "daily-schedule",
|
||||
);
|
||||
expect(dailyLlmCall).toBeDefined();
|
||||
expect(dailyLlmCall!.options.message).toContain(
|
||||
"(no schedule on file for 2 days ago)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Brain.createMonthlySchedule", () => {
|
||||
test("S2: returns N day summaries (N = days in next month) and persists a fact", async () => {
|
||||
test("S2: returns N day summaries (N = days in next month) and persists a document", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const today = new Date(2026, 0, 15);
|
||||
const expected = nextMonth(today);
|
||||
const expectedKey = `${expected.year}-${String(expected.month + 1).padStart(2, "0")}`;
|
||||
@@ -329,20 +535,16 @@ describe("Brain.createMonthlySchedule", () => {
|
||||
expect(llmCall!.options.message).toContain("study for GRE");
|
||||
expect(llmCall!.options.message).toContain("Test personality");
|
||||
|
||||
const facts = await brain.db.getTopicFacts(
|
||||
`monthly-schedule:${expectedKey}`,
|
||||
{
|
||||
spaceName: brain.space.name,
|
||||
},
|
||||
);
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(JSON.parse(facts[0]!.statement).items).toHaveLength(
|
||||
const stored = db.findByCustomId(`monthly-schedule:${expectedKey}`);
|
||||
expect(stored).toBeDefined();
|
||||
expect(JSON.parse(stored!.content).items).toHaveLength(
|
||||
expected.daysInMonth,
|
||||
);
|
||||
});
|
||||
|
||||
test("S5: year wrap (December 15 -> January next year)", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const today = new Date(2026, 11, 15);
|
||||
const expectedKey = "2027-01";
|
||||
|
||||
@@ -351,13 +553,8 @@ describe("Brain.createMonthlySchedule", () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.items).toHaveLength(31);
|
||||
|
||||
const facts = await brain.db.getTopicFacts(
|
||||
`monthly-schedule:${expectedKey}`,
|
||||
{
|
||||
spaceName: brain.space.name,
|
||||
},
|
||||
);
|
||||
expect(facts).toHaveLength(1);
|
||||
const stored = db.findByCustomId(`monthly-schedule:${expectedKey}`);
|
||||
expect(stored).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,25 +563,14 @@ describe("Brain.getTodayScheduledAvailability", () => {
|
||||
const brain = await makeBrain();
|
||||
const today = new Date(2026, 5, 10);
|
||||
const todayKey = formatDateKey(today);
|
||||
await brain.db.addFact({
|
||||
spaceName: brain.space.name,
|
||||
statement: JSON.stringify({ items: build48Slots() }),
|
||||
summary: "test daily",
|
||||
source: "test",
|
||||
confidence: 1.0,
|
||||
topics: [
|
||||
{
|
||||
name: `daily-schedule:${todayKey}`,
|
||||
category: "temporal",
|
||||
granularity: "concrete",
|
||||
},
|
||||
{
|
||||
name: "daily-schedule",
|
||||
category: "concept",
|
||||
granularity: "abstract",
|
||||
},
|
||||
{ name: todayKey, category: "temporal", granularity: "concrete" },
|
||||
],
|
||||
await brain.add({
|
||||
customId: `daily-schedule:${todayKey}`,
|
||||
content: JSON.stringify({ items: build48Slots() }),
|
||||
metadata: {
|
||||
kind: "schedule",
|
||||
source: "test",
|
||||
date: todayKey,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await brain.getTodayScheduledAvailability(today);
|
||||
@@ -417,25 +603,14 @@ describe("Brain.removeScheduledAvailability", () => {
|
||||
const brain = await makeBrain();
|
||||
const today = new Date(2026, 5, 10);
|
||||
const todayKey = formatDateKey(today);
|
||||
await brain.db.addFact({
|
||||
spaceName: brain.space.name,
|
||||
statement: JSON.stringify({ items: build48Slots() }),
|
||||
summary: "test daily",
|
||||
source: "test",
|
||||
confidence: 1.0,
|
||||
topics: [
|
||||
{
|
||||
name: `daily-schedule:${todayKey}`,
|
||||
category: "temporal",
|
||||
granularity: "concrete",
|
||||
},
|
||||
{
|
||||
name: "daily-schedule",
|
||||
category: "concept",
|
||||
granularity: "abstract",
|
||||
},
|
||||
{ name: todayKey, category: "temporal", granularity: "concrete" },
|
||||
],
|
||||
await brain.add({
|
||||
customId: `daily-schedule:${todayKey}`,
|
||||
content: JSON.stringify({ items: build48Slots() }),
|
||||
metadata: {
|
||||
kind: "schedule",
|
||||
source: "test",
|
||||
date: todayKey,
|
||||
},
|
||||
});
|
||||
|
||||
const r1 = await brain.getTodayScheduledAvailability(today);
|
||||
@@ -463,25 +638,19 @@ describe("S8: regression on existing methods", () => {
|
||||
});
|
||||
|
||||
describe("Brain.createDebug", () => {
|
||||
test("D1: returns a Brain with debug=true, the supplied personality, and no disk file created", async () => {
|
||||
const { existsSync } = await import("fs");
|
||||
const { resolve } = await import("path");
|
||||
|
||||
const before = existsSync(resolve(process.cwd(), "brainbox.db"));
|
||||
|
||||
test("D1: returns a Brain with debug=true and the supplied personality under the brain:debug namespace", async () => {
|
||||
const brain = await Brain.createDebug({ personality: "test-personality-Q" });
|
||||
|
||||
expect(brain).toBeInstanceOf(Brain);
|
||||
expect(brain.debug).toBe(true);
|
||||
expect(brain.brainbase.baseSystemPrompt).toBe("test-personality-Q");
|
||||
expect(brain.brainbase.displayName).toBe("Debug Brain");
|
||||
|
||||
const after = existsSync(resolve(process.cwd(), "brainbox.db"));
|
||||
expect(after).toBe(before);
|
||||
expect(brain.space.name).toBe("brain:debug");
|
||||
});
|
||||
|
||||
test("D2: createDailySchedule on a debug brain returns a schedule and does NOT add a fact to the DB", async () => {
|
||||
test("D2: createDailySchedule on a debug brain returns a schedule and persists to brain:debug", async () => {
|
||||
const brain = await Brain.createDebug({ personality: "p" });
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const today = new Date(2026, 5, 5);
|
||||
const tomorrow = new Date(2026, 5, 6);
|
||||
const tomorrowKey = formatDateKey(tomorrow);
|
||||
@@ -490,14 +659,14 @@ describe("Brain.createDebug", () => {
|
||||
expect(schedule).not.toBeNull();
|
||||
expect(schedule!.items).toHaveLength(48);
|
||||
|
||||
const facts = await brain.db.getTopicFacts(`daily-schedule:${tomorrowKey}`, {
|
||||
spaceName: brain.space.name,
|
||||
});
|
||||
expect(facts).toHaveLength(0);
|
||||
const stored = db.findByCustomId(`daily-schedule:${tomorrowKey}`);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.containerTag).toBe("brain:debug");
|
||||
});
|
||||
|
||||
test("D3: createMonthlySchedule on a debug brain returns a schedule and does NOT add a fact to the DB", async () => {
|
||||
test("D3: createMonthlySchedule on a debug brain returns a schedule and persists to brain:debug", async () => {
|
||||
const brain = await Brain.createDebug({ personality: "p" });
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const today = new Date(2026, 0, 15);
|
||||
const expected = nextMonth(today);
|
||||
const monthKey = `${expected.year}-${String(expected.month + 1).padStart(2, "0")}`;
|
||||
@@ -506,42 +675,12 @@ describe("Brain.createDebug", () => {
|
||||
expect(schedule).not.toBeNull();
|
||||
expect(schedule!.items).toHaveLength(expected.daysInMonth);
|
||||
|
||||
const facts = await brain.db.getTopicFacts(
|
||||
`monthly-schedule:${monthKey}`,
|
||||
{ spaceName: brain.space.name },
|
||||
);
|
||||
expect(facts).toHaveLength(0);
|
||||
const stored = db.findByCustomId(`monthly-schedule:${monthKey}`);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.containerTag).toBe("brain:debug");
|
||||
});
|
||||
});
|
||||
|
||||
const NOOP_EMBEDDING_PROVIDER = {
|
||||
model: "test-embed",
|
||||
dimensions: 4,
|
||||
async embed(_input: string): Promise<number[]> {
|
||||
return [0, 0, 0, 0];
|
||||
},
|
||||
async embedMany(inputs: string[]): Promise<number[][]> {
|
||||
return inputs.map(() => [0, 0, 0, 0]);
|
||||
},
|
||||
};
|
||||
|
||||
const SCORING_EMBEDDING_PROVIDER = {
|
||||
model: "test-embed-scoring",
|
||||
dimensions: 4,
|
||||
async embed(input: string): Promise<number[]> {
|
||||
if (input.includes("coffee")) return [1, 0, 0, 0];
|
||||
if (input.includes("pizza")) return [0, 1, 0, 0];
|
||||
return [0, 0, 1, 0];
|
||||
},
|
||||
async embedMany(inputs: string[]): Promise<number[][]> {
|
||||
return inputs.map((s) => {
|
||||
if (s.includes("coffee")) return [1, 0, 0, 0];
|
||||
if (s.includes("pizza")) return [0, 1, 0, 0];
|
||||
return [0, 0, 1, 0];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
describe("Brain.sendMessage — translateMessageHistory helper", () => {
|
||||
test("SM1: translateMessageHistory produces the documented format with persona label and timestamps", async () => {
|
||||
const { translateMessageHistory } = await import("./messageHistory");
|
||||
@@ -600,7 +739,7 @@ describe("Brain.sendMessage — tool-calling flow", () => {
|
||||
}>
|
||||
).map((t) => t.function.name);
|
||||
expect(toolNames).toContain("addReplyMessage");
|
||||
expect(toolNames).toContain("searchIdentityDB");
|
||||
expect(toolNames).toContain("searchMemory");
|
||||
});
|
||||
|
||||
test("SM4: sendMessage accumulates addReplyMessage tool calls and returns them in order", async () => {
|
||||
@@ -635,20 +774,13 @@ describe("Brain.sendMessage — tool-calling flow", () => {
|
||||
expect(out).toEqual(["어.", "왜불러"]);
|
||||
});
|
||||
|
||||
test("SM5: sendMessage feeds searchIdentityDB tool result back to the LLM", async () => {
|
||||
const brain = await makeBrain(SCORING_EMBEDDING_PROVIDER);
|
||||
const fact = await brain.db.addFact({
|
||||
spaceName: brain.space.name,
|
||||
statement: "사용자는 커피를 좋아한다",
|
||||
summary: "user loves coffee",
|
||||
source: "test",
|
||||
confidence: 1.0,
|
||||
topics: [
|
||||
{ name: "사용자", category: "entity", granularity: "concrete" },
|
||||
{ name: "커피", category: "concept", granularity: "abstract" },
|
||||
],
|
||||
test("SM5: sendMessage feeds searchMemory tool result back to the LLM", async () => {
|
||||
const brain = await makeBrain();
|
||||
await brain.add({
|
||||
customId: "fact-coffee",
|
||||
content: "사용자는 커피를 좋아한다",
|
||||
metadata: { kind: "fact", source: "test" },
|
||||
});
|
||||
await brain.indexFactEmbeddingFor(fact);
|
||||
|
||||
chatResponses = [
|
||||
{
|
||||
@@ -656,7 +788,7 @@ describe("Brain.sendMessage — tool-calling flow", () => {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_s",
|
||||
name: "searchIdentityDB",
|
||||
name: "searchMemory",
|
||||
arguments: JSON.stringify({ query: "커피" }),
|
||||
},
|
||||
],
|
||||
@@ -732,8 +864,8 @@ describe("Brain.sendMessage — tool-calling flow", () => {
|
||||
expect(userMsg!.content).toContain("하이");
|
||||
});
|
||||
|
||||
test("SM7: createDailySchedule auto-indexes the new fact so it is searchable via the provider", async () => {
|
||||
const brain = await makeBrain(SCORING_EMBEDDING_PROVIDER);
|
||||
test("SM7: createDailySchedule persists a document reachable via brain.get", async () => {
|
||||
const brain = await makeBrain();
|
||||
const today = new Date(2026, 5, 5);
|
||||
const tomorrow = new Date(2026, 5, 6);
|
||||
const tomorrowKey = formatDateKey(tomorrow);
|
||||
@@ -741,43 +873,20 @@ describe("Brain.sendMessage — tool-calling flow", () => {
|
||||
customDailySlots = build48Slots();
|
||||
await brain.createDailySchedule(today, "msg");
|
||||
|
||||
const hits = await brain.db.searchFacts({
|
||||
spaceName: brain.space.name,
|
||||
query: "slot-0",
|
||||
provider: SCORING_EMBEDDING_PROVIDER as never,
|
||||
limit: 5,
|
||||
const stored = await brain.get(`daily-schedule:${tomorrowKey}`);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.content).toContain("slot-0");
|
||||
expect(stored!.metadata).toEqual({
|
||||
kind: "schedule",
|
||||
source: "createDailySchedule",
|
||||
date: tomorrowKey,
|
||||
});
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
const matched = hits.find((h) =>
|
||||
h.statement.includes(`"activity":"slot-0"`),
|
||||
);
|
||||
expect(matched).toBeDefined();
|
||||
|
||||
const topicFacts = await brain.db.getTopicFacts(
|
||||
`daily-schedule:${tomorrowKey}`,
|
||||
{ spaceName: brain.space.name },
|
||||
);
|
||||
expect(topicFacts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("SM8: sendMessage no longer calls indexFactEmbeddings on every turn (uses per-fact init)", async () => {
|
||||
const brain = await makeBrain(NOOP_EMBEDDING_PROVIDER);
|
||||
let embedManyCalls = 0;
|
||||
const trackingProvider = {
|
||||
model: "track-embed",
|
||||
dimensions: 4,
|
||||
async embed(_input: string): Promise<number[]> {
|
||||
return [0, 0, 0, 0];
|
||||
},
|
||||
async embedMany(inputs: string[]): Promise<number[][]> {
|
||||
embedManyCalls += 1;
|
||||
return inputs.map(() => [0, 0, 0, 0]);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(brain, "embeddingProvider", {
|
||||
value: trackingProvider,
|
||||
configurable: true,
|
||||
});
|
||||
test("SM8: sendMessage does not call brain.add (no documents added during chat)", async () => {
|
||||
const brain = await makeBrain();
|
||||
const db = brain.db as unknown as MockSupermemory;
|
||||
const before = db.documentsAddCalls;
|
||||
|
||||
chatResponses = [
|
||||
{
|
||||
@@ -796,40 +905,19 @@ describe("Brain.sendMessage — tool-calling flow", () => {
|
||||
[{ sender: "user", time: new Date(2026, 5, 10, 9, 0, 0), content: "hi" }],
|
||||
[],
|
||||
);
|
||||
expect(embedManyCalls).toBe(0);
|
||||
expect(db.documentsAddCalls - before).toBe(0);
|
||||
});
|
||||
|
||||
test("SM9: initializeEmbeddings backfills missing embeddings for facts added out-of-band", async () => {
|
||||
const brain = await makeBrain(SCORING_EMBEDDING_PROVIDER);
|
||||
await brain.db.addFact({
|
||||
spaceName: brain.space.name,
|
||||
statement: "사용자는 피자를 좋아한다",
|
||||
summary: "user loves pizza",
|
||||
source: "test",
|
||||
confidence: 1.0,
|
||||
topics: [
|
||||
{ name: "사용자", category: "entity", granularity: "concrete" },
|
||||
{ name: "피자", category: "concept", granularity: "abstract" },
|
||||
],
|
||||
test("SM9: out-of-band add() facts are queryable via brain.search without backfill", async () => {
|
||||
const brain = await makeBrain();
|
||||
await brain.add({
|
||||
customId: "fact-pizza",
|
||||
content: "사용자는 피자를 좋아한다",
|
||||
metadata: { kind: "fact", source: "test" },
|
||||
});
|
||||
|
||||
let preInitHits = await brain.db.searchFacts({
|
||||
spaceName: brain.space.name,
|
||||
query: "피자",
|
||||
provider: SCORING_EMBEDDING_PROVIDER as never,
|
||||
limit: 5,
|
||||
});
|
||||
expect(preInitHits).toHaveLength(0);
|
||||
|
||||
await brain.initializeEmbeddings();
|
||||
|
||||
const postInitHits = await brain.db.searchFacts({
|
||||
spaceName: brain.space.name,
|
||||
query: "피자",
|
||||
provider: SCORING_EMBEDDING_PROVIDER as never,
|
||||
limit: 5,
|
||||
});
|
||||
expect(postInitHits.length).toBeGreaterThan(0);
|
||||
expect(postInitHits[0]!.statement).toContain("피자");
|
||||
const hits = await brain.search("피자", 5);
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
expect(hits[0]!.content).toContain("피자");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import Supermemory from "supermemory";
|
||||
import { config } from "@/config";
|
||||
import {
|
||||
IdentityDB,
|
||||
type EmbeddingProvider,
|
||||
type ExtractedFact,
|
||||
type Space,
|
||||
} from "identitydb";
|
||||
import { llm } from "@/openrouter";
|
||||
import { OpenRouterEmbeddingProvider } from "@/openrouter/embedding";
|
||||
import { loadPrompt } from "@/openrouter/promptLoader";
|
||||
import {
|
||||
availabilitySchema,
|
||||
@@ -15,11 +9,19 @@ import {
|
||||
monthlyScheduleSchema,
|
||||
type AvailabilityWindows,
|
||||
type DailySchedule,
|
||||
type DailySlot,
|
||||
type MonthlySchedule,
|
||||
} from "@/openrouter/schema";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { factExtractor } from "./factExtractor";
|
||||
import { BadRequestResponseError } from "@openrouter/sdk/models/errors";
|
||||
import type {
|
||||
ChatAssistantMessage,
|
||||
ChatChoice,
|
||||
ChatFunctionTool,
|
||||
ChatMessages,
|
||||
} from "@openrouter/sdk/models";
|
||||
import { BrainDBManager, brainManager, type BrainItem } from "./manager";
|
||||
import { MemoryStub } from "./stub";
|
||||
import {
|
||||
translateMessageHistory,
|
||||
type MessageHistoryEntry,
|
||||
@@ -31,13 +33,7 @@ import {
|
||||
nextMonth,
|
||||
pad2,
|
||||
} from "./schedule";
|
||||
import { BadRequestResponseError } from "@openrouter/sdk/models/errors";
|
||||
import type {
|
||||
ChatAssistantMessage,
|
||||
ChatChoice,
|
||||
ChatFunctionTool,
|
||||
ChatMessages,
|
||||
} from "@openrouter/sdk/models";
|
||||
import type { FactInput, FactMetadata, SearchHit, Space } from "./types";
|
||||
|
||||
export interface DebugOptions {
|
||||
personality: string;
|
||||
@@ -47,169 +43,96 @@ export interface BrainCreateResult {
|
||||
brain: Brain;
|
||||
description: string;
|
||||
baseSystemPrompt: string;
|
||||
/**
|
||||
* Raw facts as returned by `factExtractor.extract(description)`. Populated
|
||||
* only when `Brain.create` is called with `debug: true`; in production
|
||||
* (the default), facts are persisted via `db.ingestStatements` which does
|
||||
* not surface the raw extractor output to the caller.
|
||||
*/
|
||||
extractedFacts?: ExtractedFact[];
|
||||
}
|
||||
|
||||
export class Brain {
|
||||
private availabilityCache: Map<string, AvailabilityWindows> = new Map();
|
||||
private embeddingProvider: EmbeddingProvider;
|
||||
|
||||
constructor(
|
||||
public db: IdentityDB,
|
||||
public db: Supermemory | MemoryStub,
|
||||
public space: Space,
|
||||
public brainbase: BrainItem,
|
||||
public debug: boolean = false,
|
||||
embeddingProvider?: EmbeddingProvider,
|
||||
) {
|
||||
this.embeddingProvider =
|
||||
embeddingProvider ?? new OpenRouterEmbeddingProvider();
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory primitives — thin wrappers over supermemory's `documents` API.
|
||||
//
|
||||
// containerTag = space.name
|
||||
// customId = the stable lookup key (e.g. "daily-schedule:2026-06-10")
|
||||
// content = the fact text or JSON-encoded schedule
|
||||
// metadata = filterable bag: { kind, source, ... }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async add(input: FactInput): Promise<{ id: string }> {
|
||||
const response = await this.db.documents.add({
|
||||
content: input.content,
|
||||
containerTag: this.space.name,
|
||||
customId: input.customId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return { id: response.id };
|
||||
}
|
||||
|
||||
async get(
|
||||
customId: string,
|
||||
): Promise<{ content: string; metadata: FactMetadata | null } | null> {
|
||||
const listed = await this.db.documents.list({
|
||||
containerTags: [this.space.name],
|
||||
limit: 200,
|
||||
});
|
||||
const match = (listed.memories ?? []).find((m) => m.customId === customId);
|
||||
if (!match) return null;
|
||||
const full = await this.db.documents.get(match.id);
|
||||
return {
|
||||
content: full.content ?? "",
|
||||
metadata: (full.metadata ?? null) as FactMetadata | null,
|
||||
};
|
||||
}
|
||||
|
||||
async list(): Promise<Array<{ customId: string | null; content: string }>> {
|
||||
const listed = await this.db.documents.list({
|
||||
containerTags: [this.space.name],
|
||||
limit: 200,
|
||||
});
|
||||
return (listed.memories ?? []).map((d) => ({
|
||||
customId: d.customId,
|
||||
content: d.content ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
async search(query: string, limit = 5): Promise<SearchHit[]> {
|
||||
const response = await this.db.search.execute({
|
||||
q: query,
|
||||
containerTag: this.space.name,
|
||||
limit,
|
||||
onlyMatchingChunks: true,
|
||||
});
|
||||
return (response.results ?? []).map((r) => {
|
||||
const firstChunk = r.chunks?.[0];
|
||||
return {
|
||||
content: firstChunk?.content ?? "",
|
||||
score: r.score,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createDailySchedule(
|
||||
datetime: Date,
|
||||
message: string,
|
||||
): Promise<DailySchedule | null> {
|
||||
try {
|
||||
const target = nextDay(datetime);
|
||||
const dateKey = formatDateKey(target);
|
||||
const topicName = `daily-schedule:${dateKey}`;
|
||||
|
||||
const monthlySummary = await this.getMonthlySummaryForDay(target);
|
||||
const history = await this.getHistoryFacts();
|
||||
|
||||
const instruction = await loadPrompt("DAILY_SCHEDULE");
|
||||
const promptMessage = [
|
||||
`Target date: ${dateKey} (${target.toLocaleDateString("en-US", { weekday: "long" })})`,
|
||||
`Personality: ${this.brainbase.baseSystemPrompt}`,
|
||||
monthlySummary
|
||||
? `Monthly summary for this day: ${monthlySummary}`
|
||||
: "(no monthly summary available for this date)",
|
||||
`Recent history (facts):`,
|
||||
history,
|
||||
`User direction: ${message}`,
|
||||
].join("\n\n");
|
||||
|
||||
const schedule = await llm.call<DailySchedule>(llm.models.identity, {
|
||||
instruction,
|
||||
message: promptMessage,
|
||||
jsonSchemaName: "daily-schedule",
|
||||
jsonSchema: dailyScheduleSchema,
|
||||
});
|
||||
|
||||
if (!this.debug) {
|
||||
const fact = await this.db.addFact({
|
||||
spaceName: this.space.name,
|
||||
statement: JSON.stringify(schedule),
|
||||
summary: `Daily schedule for ${dateKey} (${schedule.items.length} slots)`,
|
||||
source: "createDailySchedule",
|
||||
confidence: 1.0,
|
||||
topics: [
|
||||
{
|
||||
name: topicName,
|
||||
category: "temporal",
|
||||
granularity: "concrete",
|
||||
role: "schedule",
|
||||
},
|
||||
{
|
||||
name: "daily-schedule",
|
||||
category: "concept",
|
||||
granularity: "abstract",
|
||||
role: "schedule",
|
||||
},
|
||||
{
|
||||
name: dateKey,
|
||||
category: "temporal",
|
||||
granularity: "concrete",
|
||||
role: "date",
|
||||
},
|
||||
],
|
||||
});
|
||||
await this.indexFactEmbeddingFor(fact);
|
||||
}
|
||||
|
||||
return schedule;
|
||||
} catch (error) {
|
||||
let reason =
|
||||
error instanceof Error
|
||||
? error.message + `(${error.name})`
|
||||
: String(error);
|
||||
if (error instanceof BadRequestResponseError)
|
||||
reason = reason + `${error.body}`;
|
||||
logger.error(`createDailySchedule failed: ${reason}`);
|
||||
return null;
|
||||
}
|
||||
return await runCreateDailyScheduleSteps(this, datetime, message, noopRunner);
|
||||
}
|
||||
|
||||
async createMonthlySchedule(
|
||||
datetime: Date,
|
||||
message: string,
|
||||
): Promise<MonthlySchedule | null> {
|
||||
try {
|
||||
const next = nextMonth(datetime);
|
||||
const monthKey = `${next.year}-${pad2(next.month + 1)}`;
|
||||
const topicName = `monthly-schedule:${monthKey}`;
|
||||
|
||||
const history = await this.getHistoryFacts();
|
||||
|
||||
const instruction = await loadPrompt("MONTHLY_SCHEDULE");
|
||||
const promptMessage = [
|
||||
`Target month: ${monthKey} (${next.daysInMonth} days)`,
|
||||
`Personality: ${this.brainbase.baseSystemPrompt}`,
|
||||
`Recent history (facts):`,
|
||||
history,
|
||||
`User direction: ${message}`,
|
||||
].join("\n\n");
|
||||
|
||||
const schedule = await llm.call<MonthlySchedule>(llm.models.identity, {
|
||||
instruction,
|
||||
message: promptMessage,
|
||||
jsonSchemaName: "monthly-schedule",
|
||||
jsonSchema: monthlyScheduleSchema,
|
||||
});
|
||||
|
||||
if (!this.debug) {
|
||||
const fact = await this.db.addFact({
|
||||
spaceName: this.space.name,
|
||||
statement: JSON.stringify(schedule),
|
||||
summary: `Monthly schedule for ${monthKey} (${schedule.items.length} days)`,
|
||||
source: "createMonthlySchedule",
|
||||
confidence: 1.0,
|
||||
topics: [
|
||||
{
|
||||
name: topicName,
|
||||
category: "temporal",
|
||||
granularity: "concrete",
|
||||
role: "schedule",
|
||||
},
|
||||
{
|
||||
name: "monthly-schedule",
|
||||
category: "concept",
|
||||
granularity: "abstract",
|
||||
role: "schedule",
|
||||
},
|
||||
{
|
||||
name: monthKey,
|
||||
category: "temporal",
|
||||
granularity: "concrete",
|
||||
role: "period",
|
||||
},
|
||||
],
|
||||
});
|
||||
await this.indexFactEmbeddingFor(fact);
|
||||
}
|
||||
|
||||
return schedule;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`createMonthlySchedule failed: ${reason}`);
|
||||
return null;
|
||||
}
|
||||
return await runCreateMonthlyScheduleSteps(this, datetime, message, noopRunner);
|
||||
}
|
||||
|
||||
async getTodayScheduledAvailability(
|
||||
@@ -220,20 +143,10 @@ export class Brain {
|
||||
const cached = this.availabilityCache.get(dateKey);
|
||||
if (cached) return cached;
|
||||
|
||||
if (this.debug) {
|
||||
logger.warn(
|
||||
"getTodayScheduledAvailability requires a persisted daily schedule; debug brains have no DB. Use deriveAvailabilityFromSchedule(schedule) instead.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const stored = await this.get(`daily-schedule:${dateKey}`);
|
||||
if (!stored) return null;
|
||||
|
||||
const topicName = `daily-schedule:${dateKey}`;
|
||||
const facts = await this.db.getTopicFacts(topicName, {
|
||||
spaceName: this.space.name,
|
||||
});
|
||||
if (facts.length === 0) return null;
|
||||
|
||||
const dailySchedule = JSON.parse(facts[0]!.statement) as DailySchedule;
|
||||
const dailySchedule = JSON.parse(stored.content) as DailySchedule;
|
||||
const availability =
|
||||
await this.deriveAvailabilityFromSchedule(dailySchedule);
|
||||
|
||||
@@ -246,6 +159,30 @@ export class Brain {
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentAndAdjacentSlots(now: Date): Promise<DailySlot[]> {
|
||||
const dateKey = formatDateKey(now);
|
||||
const stored = await this.get(`daily-schedule:${dateKey}`);
|
||||
if (!stored) return [];
|
||||
let schedule: DailySchedule;
|
||||
try {
|
||||
schedule = JSON.parse(stored.content) as DailySchedule;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const toMinutes = (hhmm: string): number => {
|
||||
const [h = 0, m = 0] = hhmm.split(":").map((x) => parseInt(x, 10));
|
||||
return h * 60 + m;
|
||||
};
|
||||
const index = schedule.items.findIndex(
|
||||
(slot) =>
|
||||
toMinutes(slot.start) <= currentMinutes &&
|
||||
currentMinutes < toMinutes(slot.end),
|
||||
);
|
||||
if (index === -1) return [];
|
||||
return schedule.items.slice(Math.max(0, index - 1), index + 2);
|
||||
}
|
||||
|
||||
async deriveAvailabilityFromSchedule(
|
||||
schedule: DailySchedule,
|
||||
): Promise<AvailabilityWindows> {
|
||||
@@ -273,45 +210,6 @@ export class Brain {
|
||||
this.availabilityCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds a single fact in the embedding table. Called automatically by
|
||||
* Brain methods that add facts (createDailySchedule, createMonthlySchedule,
|
||||
* Brain.create). Callers who add facts via `db.addFact` directly should
|
||||
* invoke this so the LLM can recall the fact via `searchIdentityDB`. A
|
||||
* no-op in debug mode (where there is no persisted state).
|
||||
*/
|
||||
async indexFactEmbeddingFor(fact: { id: string }): Promise<void> {
|
||||
if (this.debug) return;
|
||||
try {
|
||||
await this.db.indexFactEmbedding(fact.id, {
|
||||
spaceName: this.space.name,
|
||||
provider: this.embeddingProvider,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`indexFactEmbeddingFor(${fact.id}) failed: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills embeddings for every fact in this brain's space. Intended
|
||||
* for `Brain.create` and `Brain.load` — runs once at initialization so
|
||||
* facts added by older code paths (or out-of-band) become searchable.
|
||||
* No-op in debug mode and when the space has no facts.
|
||||
*/
|
||||
async initializeEmbeddings(): Promise<void> {
|
||||
if (this.debug) return;
|
||||
try {
|
||||
await this.db.indexFactEmbeddings({
|
||||
spaceName: this.space.name,
|
||||
provider: this.embeddingProvider,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`initializeEmbeddings failed: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
history: ReadonlyArray<MessageHistoryEntry>,
|
||||
newMessages: ReadonlyArray<MessageHistoryEntry>,
|
||||
@@ -394,7 +292,7 @@ export class Brain {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (call.function.name === "searchIdentityDB") {
|
||||
if (call.function.name === "searchMemory") {
|
||||
const result = await this.executeSearchTool(call.function.arguments);
|
||||
messages.push({
|
||||
role: "tool",
|
||||
@@ -415,7 +313,7 @@ export class Brain {
|
||||
|
||||
if (
|
||||
!hasContent &&
|
||||
toolCalls.every((c) => c.function.name === "searchIdentityDB")
|
||||
toolCalls.every((c) => c.function.name === "searchMemory")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -433,14 +331,23 @@ export class Brain {
|
||||
}
|
||||
|
||||
private async buildScheduleBlock(now: Date): Promise<string> {
|
||||
const dateKey = formatDateKey(now);
|
||||
const currentSlots = await this.getCurrentAndAdjacentSlots(now);
|
||||
const currentBlock = currentSlots.length > 0
|
||||
? `Currently (around ${now.toTimeString().slice(0, 5)}):\n${currentSlots
|
||||
.map(
|
||||
(s) =>
|
||||
` ${s.start}-${s.end} ${s.activity}${s.notes ? ` (${s.notes})` : ""}`,
|
||||
)
|
||||
.join("\n")}`
|
||||
: `Currently (${dateKey} ${now.toTimeString().slice(0, 5)}): (no matching slot in today's schedule)`;
|
||||
|
||||
const days: { label: string; date: Date }[] = [
|
||||
{
|
||||
label: "Yesterday",
|
||||
date: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1),
|
||||
},
|
||||
{ label: "Today", date: now },
|
||||
{
|
||||
label: "Tomorrow",
|
||||
{ label: "Tomorrow",
|
||||
date: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1),
|
||||
},
|
||||
];
|
||||
@@ -452,19 +359,16 @@ export class Brain {
|
||||
`${label} (${key}): ${summary ?? "(no daily schedule on file)"}`,
|
||||
);
|
||||
}
|
||||
return `Schedule context:\n${blocks.join("\n")}`;
|
||||
return `Schedule context:\n${currentBlock}\n\n${blocks.join("\n")}`;
|
||||
}
|
||||
|
||||
private async getDailyScheduleSummary(
|
||||
dateKey: string,
|
||||
): Promise<string | null> {
|
||||
if (this.debug) return null;
|
||||
try {
|
||||
const facts = await this.db.getTopicFacts(`daily-schedule:${dateKey}`, {
|
||||
spaceName: this.space.name,
|
||||
});
|
||||
if (facts.length === 0) return null;
|
||||
const schedule = JSON.parse(facts[0]!.statement) as DailySchedule;
|
||||
const stored = await this.get(`daily-schedule:${dateKey}`);
|
||||
if (!stored) return null;
|
||||
const schedule = JSON.parse(stored.content) as DailySchedule;
|
||||
const first = schedule.items[0];
|
||||
const last = schedule.items[schedule.items.length - 1];
|
||||
if (!first || !last) return null;
|
||||
@@ -481,15 +385,9 @@ export class Brain {
|
||||
return JSON.stringify({ ok: false, error: "missing query" });
|
||||
}
|
||||
try {
|
||||
const hits = await this.db.searchFacts({
|
||||
spaceName: this.space.name,
|
||||
query,
|
||||
provider: this.embeddingProvider,
|
||||
limit: 5,
|
||||
});
|
||||
const hits = await this.search(query, 5);
|
||||
const compact = hits.map((hit) => ({
|
||||
statement: hit.statement,
|
||||
summary: hit.summary,
|
||||
content: hit.content,
|
||||
score: hit.score,
|
||||
}));
|
||||
return JSON.stringify({ ok: true, hits: compact });
|
||||
@@ -499,17 +397,13 @@ export class Brain {
|
||||
}
|
||||
}
|
||||
|
||||
private async getMonthlySummaryForDay(target: Date): Promise<string | null> {
|
||||
if (this.debug) return null;
|
||||
async getMonthlySummaryForDay(target: Date): Promise<string | null> {
|
||||
try {
|
||||
const monthKey = formatMonthKey(target);
|
||||
const topicName = `monthly-schedule:${monthKey}`;
|
||||
const facts = await this.db.getTopicFacts(topicName, {
|
||||
spaceName: this.space.name,
|
||||
});
|
||||
if (facts.length === 0) return null;
|
||||
const stored = await this.get(`monthly-schedule:${monthKey}`);
|
||||
if (!stored) return null;
|
||||
|
||||
const monthly = JSON.parse(facts[0]!.statement) as MonthlySchedule;
|
||||
const monthly = JSON.parse(stored.content) as MonthlySchedule;
|
||||
const day = target.getDate();
|
||||
const entry = monthly.items.find((d) => d.day === day);
|
||||
return entry?.summary ?? null;
|
||||
@@ -518,21 +412,13 @@ export class Brain {
|
||||
}
|
||||
}
|
||||
|
||||
private async getHistoryFacts(): Promise<string> {
|
||||
if (this.debug) return "";
|
||||
async getHistoryFacts(): Promise<string> {
|
||||
try {
|
||||
const topics = await this.db.listTopics({
|
||||
spaceName: this.space.name,
|
||||
includeFacts: true,
|
||||
});
|
||||
const statements: string[] = [];
|
||||
for (const topic of topics) {
|
||||
const t = topic as { facts?: Array<{ statement: string }> };
|
||||
if (t.facts) {
|
||||
for (const f of t.facts) statements.push(f.statement);
|
||||
}
|
||||
}
|
||||
return statements.slice(-30).join("\n");
|
||||
const docs = await this.list();
|
||||
return docs
|
||||
.map((d) => d.content)
|
||||
.slice(-30)
|
||||
.join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -541,136 +427,301 @@ export class Brain {
|
||||
static async create(
|
||||
displayName: string,
|
||||
seed: string,
|
||||
options: {
|
||||
dbPath?: string;
|
||||
braindbPath?: string;
|
||||
debug?: boolean;
|
||||
embeddingProvider?: EmbeddingProvider;
|
||||
} = {},
|
||||
options: { braindbPath?: string; db?: Supermemory | MemoryStub } = {},
|
||||
): Promise<BrainCreateResult | null> {
|
||||
const dbPath = options.dbPath ?? config.dbPath;
|
||||
const manager = options.braindbPath
|
||||
? new BrainDBManager(options.braindbPath)
|
||||
: brainManager;
|
||||
const embeddingProvider =
|
||||
options.embeddingProvider ?? new OpenRouterEmbeddingProvider();
|
||||
try {
|
||||
const personaInitInstruction = await loadPrompt("PERSONA_INIT");
|
||||
const description = await llm.call<string>(llm.models.identity, {
|
||||
instruction: personaInitInstruction,
|
||||
message: seed,
|
||||
});
|
||||
|
||||
const personaSystemInstruction = await loadPrompt(
|
||||
"PERSONA_BASE_SYSTEM_PROMPT",
|
||||
);
|
||||
const generatedBaseSystemPrompt = await llm.call<string>(
|
||||
llm.models.identity,
|
||||
{
|
||||
instruction: personaSystemInstruction,
|
||||
message: description,
|
||||
},
|
||||
);
|
||||
|
||||
const personaSystemFixed = await loadPrompt(
|
||||
"PERSONA_BASE_SYSTEM_PROMPT_FIXED",
|
||||
);
|
||||
const baseSystemPrompt = `${generatedBaseSystemPrompt}\n\n${personaSystemFixed}`;
|
||||
|
||||
const db = await IdentityDB.connect({
|
||||
client: "sqlite",
|
||||
filename: dbPath,
|
||||
});
|
||||
await db.initialize();
|
||||
const brainId = randomUUID();
|
||||
const spaceName = `brain:${brainId}`;
|
||||
const space = await db.upsertSpace({
|
||||
name: spaceName,
|
||||
description: displayName,
|
||||
});
|
||||
|
||||
let extractedFacts: ExtractedFact[] | undefined;
|
||||
if (options.debug) {
|
||||
extractedFacts = await factExtractor.extract(description);
|
||||
for (const fact of extractedFacts) {
|
||||
const created = await db.addFact({
|
||||
spaceName,
|
||||
statement: fact.statement ?? description,
|
||||
summary: fact.summary,
|
||||
source: fact.source,
|
||||
confidence: fact.confidence,
|
||||
topics: fact.topics,
|
||||
metadata: fact.metadata,
|
||||
});
|
||||
await db.indexFactEmbedding(created.id, {
|
||||
spaceName,
|
||||
provider: embeddingProvider,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await db.ingestStatements(description, {
|
||||
extractor: factExtractor,
|
||||
embeddingProvider,
|
||||
spaceName,
|
||||
});
|
||||
}
|
||||
|
||||
const brainbase: BrainItem = {
|
||||
brainId,
|
||||
spaceName,
|
||||
displayName,
|
||||
baseSystemPrompt,
|
||||
};
|
||||
await manager.saveBrain(brainId, brainbase);
|
||||
|
||||
const brain = new Brain(db, space, brainbase, false, embeddingProvider);
|
||||
return { brain, description, baseSystemPrompt, extractedFacts };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to create brain "${displayName}": ${reason}`);
|
||||
return null;
|
||||
}
|
||||
return await runCreateSteps(displayName, seed, options, noopRunner);
|
||||
}
|
||||
|
||||
static async load(brainId: string): Promise<Brain | null> {
|
||||
const brain = await brainManager.loadBrain(brainId);
|
||||
if (!brain) return null;
|
||||
const brainbase = await brainManager.loadBrain(brainId);
|
||||
if (!brainbase) return null;
|
||||
|
||||
const db = await IdentityDB.connect({
|
||||
client: "sqlite",
|
||||
filename: config.dbPath,
|
||||
});
|
||||
|
||||
const space = await db.getSpaceByName(brain.spaceName);
|
||||
if (!space) return null;
|
||||
|
||||
const brainInstance = new Brain(db, space, brain);
|
||||
await brainInstance.initializeEmbeddings();
|
||||
return brainInstance;
|
||||
const db = new Supermemory({ apiKey: config.supermemoryApiKey });
|
||||
const space: Space = { name: brainbase.spaceName };
|
||||
return new Brain(db, space, brainbase);
|
||||
}
|
||||
|
||||
static async createDebug(options: DebugOptions): Promise<Brain> {
|
||||
const db = await IdentityDB.connect({
|
||||
client: "sqlite",
|
||||
filename: ":memory:",
|
||||
});
|
||||
await db.initialize();
|
||||
const space = await db.upsertSpace({
|
||||
name: "debug",
|
||||
description: "Debug Brain",
|
||||
});
|
||||
|
||||
static async createDebug(
|
||||
options: DebugOptions,
|
||||
db?: Supermemory | MemoryStub,
|
||||
): Promise<Brain> {
|
||||
const client = db ?? new Supermemory({ apiKey: config.supermemoryApiKey });
|
||||
const space: Space = { name: "brain:debug", description: "Debug Brain" };
|
||||
const brainbase: BrainItem = {
|
||||
brainId: "debug",
|
||||
spaceName: "debug",
|
||||
spaceName: space.name,
|
||||
displayName: "Debug Brain",
|
||||
baseSystemPrompt: options.personality,
|
||||
};
|
||||
|
||||
return new Brain(db, space, brainbase, true);
|
||||
return new Brain(client, space, brainbase, true);
|
||||
}
|
||||
}
|
||||
|
||||
export type ScheduleStep =
|
||||
| { kind: "gather-context" }
|
||||
| {
|
||||
kind: "generate-schedule";
|
||||
jsonSchemaName: string;
|
||||
schedule: DailySchedule | MonthlySchedule;
|
||||
}
|
||||
| { kind: "persist-schedule"; customId: string; contentLength: number }
|
||||
| { kind: "derive-availability"; availability: AvailabilityWindows };
|
||||
|
||||
export type ScheduleProgress = (step: ScheduleStep) => void;
|
||||
|
||||
const noScheduleProgress: ScheduleProgress = () => {};
|
||||
|
||||
export interface StepRunner {
|
||||
start(label: string): void;
|
||||
done(summary: string): void;
|
||||
fail(reason: string): void;
|
||||
}
|
||||
|
||||
const noopRunner: StepRunner = {
|
||||
start: () => {},
|
||||
done: () => {},
|
||||
fail: () => {},
|
||||
};
|
||||
|
||||
export async function runCreateDailyScheduleSteps(
|
||||
brain: Brain,
|
||||
datetime: Date,
|
||||
message: string,
|
||||
runner: StepRunner = noopRunner,
|
||||
): Promise<DailySchedule | null> {
|
||||
try {
|
||||
runner.start("gathering context");
|
||||
const target = nextDay(datetime);
|
||||
const dateKey = formatDateKey(target);
|
||||
const twoDaysAgo = new Date(target);
|
||||
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
||||
const twoDaysAgoKey = formatDateKey(twoDaysAgo);
|
||||
const [monthlySummary, history, twoDaysAgoStored] = await Promise.all([
|
||||
brain.getMonthlySummaryForDay(target),
|
||||
brain.getHistoryFacts(),
|
||||
brain.get(`daily-schedule:${twoDaysAgoKey}`),
|
||||
]);
|
||||
let twoDaysAgoSchedule: DailySchedule | null = null;
|
||||
if (twoDaysAgoStored) {
|
||||
try {
|
||||
twoDaysAgoSchedule = JSON.parse(twoDaysAgoStored.content) as DailySchedule;
|
||||
} catch {
|
||||
twoDaysAgoSchedule = null;
|
||||
}
|
||||
}
|
||||
runner.done("");
|
||||
|
||||
runner.start("generating schedule (daily-schedule)");
|
||||
const instruction = await loadPrompt("DAILY_SCHEDULE");
|
||||
const promptMessage = [
|
||||
`Target date: ${dateKey} (${target.toLocaleDateString("en-US", { weekday: "long" })})`,
|
||||
`Personality: ${brain.brainbase.baseSystemPrompt}`,
|
||||
monthlySummary
|
||||
? `Monthly summary for this day: ${monthlySummary}`
|
||||
: "(no monthly summary available for this date)",
|
||||
`Recent schedule (${twoDaysAgoKey}, 2 days ago): ${
|
||||
twoDaysAgoSchedule
|
||||
? twoDaysAgoSchedule.items
|
||||
.map((s) => `${s.start} ${s.activity}`)
|
||||
.join(", ")
|
||||
: "(no schedule on file for 2 days ago)"
|
||||
}`,
|
||||
`Recent history (facts):`,
|
||||
history,
|
||||
`User direction: ${message}`,
|
||||
].join("\n\n");
|
||||
|
||||
const schedule = await llm.call<DailySchedule>(llm.models.identity, {
|
||||
instruction,
|
||||
message: promptMessage,
|
||||
jsonSchemaName: "daily-schedule",
|
||||
jsonSchema: dailyScheduleSchema,
|
||||
});
|
||||
runner.done(`${schedule.items.length} items`);
|
||||
|
||||
runner.start("persisting schedule");
|
||||
await brain.add({
|
||||
customId: `daily-schedule:${dateKey}`,
|
||||
content: JSON.stringify(schedule),
|
||||
metadata: {
|
||||
kind: "schedule",
|
||||
source: "createDailySchedule",
|
||||
date: dateKey,
|
||||
},
|
||||
});
|
||||
runner.done(`customId=daily-schedule:${dateKey}`);
|
||||
|
||||
return schedule;
|
||||
} catch (error) {
|
||||
let reason =
|
||||
error instanceof Error
|
||||
? error.message + `(${error.name})`
|
||||
: String(error);
|
||||
if (error instanceof BadRequestResponseError)
|
||||
reason = reason + `${error.body}`;
|
||||
logger.error(`createDailySchedule failed: ${reason}`);
|
||||
runner.fail(reason);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCreateMonthlyScheduleSteps(
|
||||
brain: Brain,
|
||||
datetime: Date,
|
||||
message: string,
|
||||
runner: StepRunner = noopRunner,
|
||||
): Promise<MonthlySchedule | null> {
|
||||
try {
|
||||
runner.start("gathering context");
|
||||
const next = nextMonth(datetime);
|
||||
const monthKey = `${next.year}-${pad2(next.month + 1)}`;
|
||||
const twoMonthsAgo = new Date(next.year, next.month - 2, 1);
|
||||
const twoMonthsAgoKey = `${twoMonthsAgo.getFullYear()}-${pad2(twoMonthsAgo.getMonth() + 1)}`;
|
||||
const [history, twoMonthsAgoStored] = await Promise.all([
|
||||
brain.getHistoryFacts(),
|
||||
brain.get(`monthly-schedule:${twoMonthsAgoKey}`),
|
||||
]);
|
||||
let twoMonthsAgoSchedule: MonthlySchedule | null = null;
|
||||
if (twoMonthsAgoStored) {
|
||||
try {
|
||||
twoMonthsAgoSchedule = JSON.parse(twoMonthsAgoStored.content) as MonthlySchedule;
|
||||
} catch {
|
||||
twoMonthsAgoSchedule = null;
|
||||
}
|
||||
}
|
||||
runner.done("");
|
||||
|
||||
runner.start("generating schedule (monthly-schedule)");
|
||||
const instruction = await loadPrompt("MONTHLY_SCHEDULE");
|
||||
const promptMessage = [
|
||||
`Target month: ${monthKey} (${next.daysInMonth} days)`,
|
||||
`Personality: ${brain.brainbase.baseSystemPrompt}`,
|
||||
`Recent schedule (${twoMonthsAgoKey}, 2 months ago): ${
|
||||
twoMonthsAgoSchedule
|
||||
? twoMonthsAgoSchedule.items
|
||||
.map((s) => `Day ${s.day}: ${s.summary}`)
|
||||
.join(", ")
|
||||
: "(no schedule on file for 2 months ago)"
|
||||
}`,
|
||||
`Recent history (facts):`,
|
||||
history,
|
||||
`User direction: ${message}`,
|
||||
].join("\n\n");
|
||||
|
||||
const schedule = await llm.call<MonthlySchedule>(llm.models.identity, {
|
||||
instruction,
|
||||
message: promptMessage,
|
||||
jsonSchemaName: "monthly-schedule",
|
||||
jsonSchema: monthlyScheduleSchema,
|
||||
});
|
||||
runner.done(`${schedule.items.length} items`);
|
||||
|
||||
runner.start("persisting schedule");
|
||||
await brain.add({
|
||||
customId: `monthly-schedule:${monthKey}`,
|
||||
content: JSON.stringify(schedule),
|
||||
metadata: {
|
||||
kind: "schedule",
|
||||
source: "createMonthlySchedule",
|
||||
month: monthKey,
|
||||
},
|
||||
});
|
||||
runner.done(`customId=monthly-schedule:${monthKey}`);
|
||||
|
||||
return schedule;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`createMonthlySchedule failed: ${reason}`);
|
||||
runner.fail(reason);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCreateSteps(
|
||||
displayName: string,
|
||||
seed: string,
|
||||
options: { braindbPath?: string; db?: Supermemory | MemoryStub } = {},
|
||||
runner: StepRunner = noopRunner,
|
||||
): Promise<BrainCreateResult | null> {
|
||||
const manager = options.braindbPath
|
||||
? new BrainDBManager(options.braindbPath)
|
||||
: brainManager;
|
||||
try {
|
||||
runner.start("generating persona description (PERSONA_INIT)");
|
||||
const personaInitInstruction = await loadPrompt("PERSONA_INIT");
|
||||
const description = await llm.call<string>(llm.models.identity, {
|
||||
instruction: personaInitInstruction,
|
||||
message: seed,
|
||||
});
|
||||
runner.done(snippet80(description));
|
||||
|
||||
runner.start(
|
||||
"generating base system prompt (PERSONA_BASE_SYSTEM_PROMPT + FIXED)",
|
||||
);
|
||||
const personaSystemInstruction = await loadPrompt(
|
||||
"PERSONA_BASE_SYSTEM_PROMPT",
|
||||
);
|
||||
const generatedBaseSystemPrompt = await llm.call<string>(
|
||||
llm.models.identity,
|
||||
{
|
||||
instruction: personaSystemInstruction,
|
||||
message: description,
|
||||
},
|
||||
);
|
||||
|
||||
const personaSystemFixed = await loadPrompt(
|
||||
"PERSONA_BASE_SYSTEM_PROMPT_FIXED",
|
||||
);
|
||||
const baseSystemPrompt = `${generatedBaseSystemPrompt}\n\n${personaSystemFixed}`;
|
||||
runner.done(snippet80(baseSystemPrompt));
|
||||
|
||||
const db =
|
||||
options.db ?? new Supermemory({ apiKey: config.supermemoryApiKey });
|
||||
const brainId = randomUUID();
|
||||
const space: Space = {
|
||||
name: `brain:${brainId}`,
|
||||
description: displayName,
|
||||
};
|
||||
|
||||
const brain = new Brain(db, space, {
|
||||
brainId,
|
||||
spaceName: space.name,
|
||||
displayName,
|
||||
baseSystemPrompt,
|
||||
});
|
||||
|
||||
runner.start("persisting persona document");
|
||||
await brain.add({
|
||||
customId: "persona",
|
||||
content: description,
|
||||
metadata: { kind: "persona", source: "persona-init" },
|
||||
});
|
||||
runner.done(`customId=persona, contentLength=${description.length}`);
|
||||
|
||||
runner.start("saving braindb index");
|
||||
const brainbase: BrainItem = {
|
||||
brainId,
|
||||
spaceName: space.name,
|
||||
displayName,
|
||||
baseSystemPrompt,
|
||||
};
|
||||
await manager.saveBrain(brainId, brainbase);
|
||||
runner.done(`brainId=${brainId}`);
|
||||
|
||||
return { brain, description, baseSystemPrompt };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to create brain "${displayName}": ${reason}`);
|
||||
runner.fail(reason);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function snippet80(text: string): string {
|
||||
const flat = text.replace(/\s+/g, " ").trim();
|
||||
return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
|
||||
}
|
||||
|
||||
function formatDatetime(now: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(
|
||||
@@ -699,9 +750,9 @@ function buildSendMessageTools(): ChatFunctionTool[] {
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "searchIdentityDB",
|
||||
name: "searchMemory",
|
||||
description:
|
||||
"Semantic search over the long-term memory of facts about the persona and the user. Returns the most relevant stored statements for a natural-language query.",
|
||||
"Semantic search over the long-term memory of facts about the persona and the user. Returns the most relevant stored content for a natural-language query.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
|
||||
148
src/brain/stub.ts
Normal file
148
src/brain/stub.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* In-memory implementation of the supermemory SDK surface that the
|
||||
* `Brain` class uses. Lets debug commands exercise the full Brain
|
||||
* flow (including `Brain.add`, `Brain.get`, `Brain.search`, etc.)
|
||||
* without any network calls or API key.
|
||||
*
|
||||
* Storage shape: a `Map<id, StoredDoc>` keyed by an internal id; lookups
|
||||
* by `customId` are done by linear scan. `containerTag` is recorded at
|
||||
* `add()` time and filtered on `list()`. `search.execute` does a
|
||||
* case-insensitive substring match against `content`.
|
||||
*
|
||||
* This is NOT a complete supermemory clone — it only implements the
|
||||
* methods Brain calls. Adding a new Brain method that needs a
|
||||
* different SDK method will require extending this stub.
|
||||
*/
|
||||
interface StoredDoc {
|
||||
id: string;
|
||||
customId: string | null;
|
||||
containerTag: string;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class MemoryStub {
|
||||
readonly docs = new Map<string, StoredDoc>();
|
||||
private nextId = 0;
|
||||
|
||||
documents = {
|
||||
add: async (params: {
|
||||
content: string;
|
||||
containerTag: string;
|
||||
customId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => {
|
||||
const id = `stub-${++this.nextId}`;
|
||||
this.docs.set(id, {
|
||||
id,
|
||||
customId: params.customId ?? null,
|
||||
containerTag: params.containerTag,
|
||||
content: params.content,
|
||||
summary: null,
|
||||
metadata: params.metadata ?? null,
|
||||
});
|
||||
return { id, status: "done" };
|
||||
},
|
||||
list: async (params: { containerTags?: Array<string>; limit?: number }) => {
|
||||
const tags = params.containerTags ?? [];
|
||||
const limit = params.limit ?? 200;
|
||||
const all = Array.from(this.docs.values()).filter((d) =>
|
||||
tags.length === 0 ? true : tags.includes(d.containerTag),
|
||||
);
|
||||
const memories = all.slice(0, limit).map((d) => ({
|
||||
id: d.id,
|
||||
customId: d.customId,
|
||||
containerTag: d.containerTag,
|
||||
content: d.content,
|
||||
summary: d.summary,
|
||||
metadata: d.metadata,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
status: "done" as const,
|
||||
type: "text" as const,
|
||||
connectionId: null,
|
||||
filepath: null,
|
||||
title: null,
|
||||
}));
|
||||
return {
|
||||
memories,
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
totalItems: memories.length,
|
||||
totalPages: 1,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const d = this.docs.get(id);
|
||||
if (!d) {
|
||||
throw new Error(`MemoryStub.documents.get: no such id ${id}`);
|
||||
}
|
||||
return {
|
||||
id: d.id,
|
||||
customId: d.customId,
|
||||
containerTag: d.containerTag,
|
||||
content: d.content,
|
||||
summary: d.summary,
|
||||
metadata: d.metadata,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
status: "done" as const,
|
||||
type: "text" as const,
|
||||
connectionId: null,
|
||||
filepath: null,
|
||||
title: null,
|
||||
source: null,
|
||||
ogImage: null,
|
||||
raw: null,
|
||||
spatialPoint: null,
|
||||
taskType: "memory" as const,
|
||||
url: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
search = {
|
||||
execute: async (params: {
|
||||
q: string;
|
||||
containerTag?: string;
|
||||
limit?: number;
|
||||
onlyMatchingChunks?: boolean;
|
||||
}) => {
|
||||
const q = params.q.toLowerCase();
|
||||
const limit = params.limit ?? 5;
|
||||
const hits = Array.from(this.docs.values())
|
||||
.filter(
|
||||
(d) =>
|
||||
(params.containerTag
|
||||
? d.containerTag === params.containerTag
|
||||
: true) && d.content.toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((d, i) => ({
|
||||
chunks: [
|
||||
{
|
||||
content: d.content,
|
||||
isRelevant: true,
|
||||
score: 1 - i * 0.1,
|
||||
},
|
||||
],
|
||||
summary: d.summary,
|
||||
score: 1 - i * 0.1,
|
||||
documentId: d.id,
|
||||
metadata: d.metadata as Record<string, unknown> | null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
title: d.customId,
|
||||
type: "text" as const,
|
||||
}));
|
||||
return {
|
||||
results: hits,
|
||||
total: hits.length,
|
||||
timing: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
26
src/brain/types.ts
Normal file
26
src/brain/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** A brain's logical namespace. Maps to supermemory's `containerTag`. */
|
||||
export interface Space {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Metadata bag stored alongside a document in supermemory. */
|
||||
export type FactMetadata = Record<string, string | number | boolean | string[]>;
|
||||
|
||||
/**
|
||||
* A fact/document to store in the brain's memory. Mirrors supermemory's
|
||||
* `documents.add` parameter shape: `content` is the canonical text (or
|
||||
* JSON-encoded schedule), `customId` is the stable lookup key, and
|
||||
* `metadata` is the filterable bag.
|
||||
*/
|
||||
export interface FactInput {
|
||||
customId: string;
|
||||
content: string;
|
||||
metadata?: FactMetadata;
|
||||
}
|
||||
|
||||
/** A single semantic-search hit. */
|
||||
export interface SearchHit {
|
||||
content: string;
|
||||
score: number;
|
||||
}
|
||||
Reference in New Issue
Block a user