From cf584bd00bf39946c4b1d9fcebfdadbc76d82b70 Mon Sep 17 00:00:00 2001 From: p-sw Date: Thu, 25 Jun 2026 22:32:48 +0900 Subject: [PATCH] feat: add sleepMemory implementation --- prompts/memoir.md | 26 ++++++++++ src/brain/index.test.ts | 95 ++++++++++++++++++++++++++++++++++ src/brain/index.ts | 43 +++++++++++++++ src/openrouter/promptLoader.ts | 1 + 4 files changed, 165 insertions(+) create mode 100644 prompts/memoir.md diff --git a/prompts/memoir.md b/prompts/memoir.md new file mode 100644 index 0000000..6373de0 --- /dev/null +++ b/prompts/memoir.md @@ -0,0 +1,26 @@ +You are the persona described in the **Personality** section below, writing a private journal entry at the end of the day. The entry is for your own recollection tomorrow morning. It is not addressed to anyone else. + +### INPUT FORMAT + +You will receive a single message containing the following labeled sections: + +- **Personality:** The character's full psychological operating system, first-person. Voice, vocabulary, temperament, values, anxieties, what they pay attention to. +- **Date:** A `YYYY-MM-DD` for the day being recorded. +- **Conversation log:** The full message history for that day, formatted as `{persona name}@{timestamp}: message` per line. The persona is referred to by their name; the other party is referred to as `사용자` (or by name if it appears in the personality/history). + +### TASK + +Write a short memoir passage — one to three short paragraphs — about the persona's day, captured entirely from the conversation log above. + +- The persona is referred to in **third person** by their name. The entry is a recollection, not a present-tense chat reply. +- Do **not** invent facts, events, locations, or feelings that are not present in the conversation log. If a topic did not come up, it does not appear. +- Capture the emotional weather, what mattered, what was unresolved — not a play-by-play transcript. Do not paraphrase message-for-message. +- Filter every sentence through the persona's documented voice and temperament. The entry should sound like that person would sound writing privately about their own day. +- Plain prose. No bullet points, no numbered lists, no markdown headers, no JSON. + +### ABSOLUTE RULES + +1. Only facts present in the conversation log may appear. Silence on a topic is silence in the entry. +2. The user is referred to as `사용자` or by name if their name is given. Never as `the user`. +3. The persona is never referred to in first person. No `나`, `저`, `내`, `제`, `I`, `me`, `my`. +4. Output prose only. \ No newline at end of file diff --git a/src/brain/index.test.ts b/src/brain/index.test.ts index 0784c9f..7486b64 100644 --- a/src/brain/index.test.ts +++ b/src/brain/index.test.ts @@ -1072,3 +1072,98 @@ describe("Brain.sendMessage — tool-calling flow", () => { expect(hits[0]!.content).toContain("피자"); }); }); + +describe("Brain.sleepMemory", () => { + test("SJM1: persists a daily-journal document and returns the LLM text", async () => { + const brain = await makeBrain(); + const db = brain.db as unknown as MockSupermemory; + const datetime = new Date(2026, 5, 10, 23, 0, 0); + const dateKey = formatDateKey(datetime); + const history = [ + { + sender: "user" as const, + time: new Date(2026, 5, 10, 9, 0, 0), + content: "오늘 뭐 했어?", + }, + { + sender: "persona" as const, + time: new Date(2026, 5, 10, 9, 0, 30), + content: "그냥 잤어", + }, + ]; + + const result = await brain.sleepMemory(datetime, history); + + expect(result).toBe("test-description"); + const memoirCall = llmCalls.find( + (c) => + c.options.jsonSchemaName === undefined && + typeof c.options.message === "string" && + c.options.message.includes("Conversation log:"), + ); + expect(memoirCall).toBeDefined(); + expect(memoirCall!.options.message).toContain(`Date: ${dateKey}`); + expect(memoirCall!.options.message).toContain("Test personality"); + expect(memoirCall!.options.message).toContain("오늘 뭐 했어?"); + expect(memoirCall!.options.message).toContain("그냥 잤어"); + expect(memoirCall!.options.message).toContain("사용자@"); + + const stored = db.findByCustomId(`daily-journal:${dateKey}`); + expect(stored).toBeDefined(); + expect(stored!.containerTag).toBe(brain.space.name); + expect(stored!.content).toBe("test-description"); + expect(stored!.metadata).toEqual({ + kind: "daily-journal", + source: "sleepMemory", + date: dateKey, + }); + }); + + test("SJM2: empty history returns null without calling the LLM", async () => { + const brain = await makeBrain(); + const datetime = new Date(2026, 5, 10, 23, 0, 0); + + llmCalls.length = 0; + const result = await brain.sleepMemory(datetime, []); + + expect(result).toBeNull(); + expect(llmCalls).toHaveLength(0); + }); + + test("SJM3: stored daily-journal is readable via brain.get with kind=daily-journal", async () => { + const brain = await makeBrain(); + const datetime = new Date(2026, 5, 10, 23, 0, 0); + const dateKey = formatDateKey(datetime); + + await brain.sleepMemory(datetime, [ + { + sender: "user" as const, + time: new Date(2026, 5, 10, 9, 0, 0), + content: "hi", + }, + ]); + + const stored = await brain.get(`daily-journal:${dateKey}`); + expect(stored).not.toBeNull(); + expect(stored!.content).toBe("test-description"); + expect(stored!.metadata?.kind).toBe("daily-journal"); + }); + + test("SJM4: datetime defaults to today when undefined is passed", async () => { + const brain = await makeBrain(); + const db = brain.db as unknown as MockSupermemory; + const expectedKey = formatDateKey(new Date()); + + const result = await brain.sleepMemory(undefined as unknown as Date, [ + { + sender: "user" as const, + time: new Date(), + content: "ping", + }, + ]); + + expect(result).toBe("test-description"); + const stored = db.findByCustomId(`daily-journal:${expectedKey}`); + expect(stored).toBeDefined(); + }); +}); diff --git a/src/brain/index.ts b/src/brain/index.ts index 76e665c..8e51320 100644 --- a/src/brain/index.ts +++ b/src/brain/index.ts @@ -135,6 +135,49 @@ export class Brain { return await runCreateMonthlyScheduleSteps(this, datetime, message, noopRunner); } + async sleepMemory( + datetime: Date = new Date(), + history: ReadonlyArray, + ): Promise { + if (history.length === 0) return null; + + try { + const dateKey = formatDateKey(datetime); + const instruction = await loadPrompt("MEMOIR"); + const historyBlock = translateMessageHistory( + this.brainbase.displayName, + history, + ); + const promptMessage = [ + `Date: ${dateKey}`, + `Personality: ${this.brainbase.baseSystemPrompt}`, + `Conversation log:`, + historyBlock, + ].join("\n\n"); + + const memoir = await llm.call(llm.models.identity, { + instruction, + message: promptMessage, + }); + + await this.add({ + customId: `daily-journal:${dateKey}`, + content: memoir, + metadata: { + kind: "daily-journal", + source: "sleepMemory", + date: dateKey, + }, + }); + + return memoir; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`sleepMemory failed: ${reason}`); + return null; + } + } + async getTodayScheduledAvailability( datetime: Date, ): Promise { diff --git a/src/openrouter/promptLoader.ts b/src/openrouter/promptLoader.ts index 38db66e..6229c09 100644 --- a/src/openrouter/promptLoader.ts +++ b/src/openrouter/promptLoader.ts @@ -11,6 +11,7 @@ const prompts = [ "OBJECTIFIER", "SEND_MESSAGE", "START_CONVERSATION", + "MEMOIR", ] as const; export type PromptKey = (typeof prompts)[number];