From 0af5f875bb3947dafbd29835e05c25f3f29e32fc Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 10 Apr 2026 01:25:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(palace):=20auto-save=20hooks=20=E2=80=94?= =?UTF-8?q?=20extract=20memories=20on=20note=20create/update=20(N4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/lib/note-hooks.ts | 3 + .../src/modules/palace/palace-hooks.test.ts | 129 ++++++++++++++++++ backend/src/modules/palace/palace-hooks.ts | 75 ++++++++++ 3 files changed, 207 insertions(+) create mode 100644 backend/src/modules/palace/palace-hooks.test.ts create mode 100644 backend/src/modules/palace/palace-hooks.ts diff --git a/backend/src/lib/note-hooks.ts b/backend/src/lib/note-hooks.ts index b895cea..5768656 100644 --- a/backend/src/lib/note-hooks.ts +++ b/backend/src/lib/note-hooks.ts @@ -11,6 +11,7 @@ import { llm } from './llm.js'; import { trackEvent } from './telemetry.js'; import type { NoteDoc } from '../modules/notes/types.js'; import { getCollection } from './datastore.js'; +import { onNoteSavedToPalace } from '../modules/palace/palace-hooks.js'; import type { FastifyBaseLogger } from 'fastify'; const MIN_WORDS_FOR_SUMMARY = 300; @@ -22,10 +23,12 @@ const MIN_WORDS_FOR_SUMMARY = 300; export function runPostSaveHooks( note: NoteDoc, log: FastifyBaseLogger, + workspaceName?: string, ): void { // Fire-and-forget — errors are logged, never thrown void backgroundEmbed(note, log); void backgroundAutoSummarize(note, log); + void onNoteSavedToPalace(note, workspaceName ?? 'Untitled', log); } /** diff --git a/backend/src/modules/palace/palace-hooks.test.ts b/backend/src/modules/palace/palace-hooks.test.ts new file mode 100644 index 0000000..d9f75df --- /dev/null +++ b/backend/src/modules/palace/palace-hooks.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetMemoryDatastore, TEST_USER_ID, TEST_PRODUCT_ID } from '../../test-helpers.js'; +import { onNoteSavedToPalace } from './palace-hooks.js'; +import { listWings, listRooms, listMemories } from './repository.js'; +import type { NoteDoc } from '../notes/types.js'; + +const USER_A = TEST_USER_ID; +const USER_B = 'test-user-2'; +const PRODUCT = TEST_PRODUCT_ID; + +const mockLog = { + info: () => {}, + warn: () => {}, + debug: () => {}, + error: () => {}, + fatal: () => {}, + trace: () => {}, + child: () => mockLog, +} as unknown as import('fastify').FastifyBaseLogger; + +function makeNote(overrides: Partial = {}): NoteDoc { + return { + id: 'note-1', + productId: PRODUCT, + workspaceId: 'ws-1', + userId: USER_A, + title: 'Test Note', + body: 'Decision: Use JWT for auth\nFound: Old sessions are deprecated\nPrefer: Short-lived tokens', + status: 'active', + tags: [], + links: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: USER_A, + updatedBy: USER_A, + ...overrides, + }; +} + +describe('Palace Auto-Save Hooks (N4)', () => { + beforeEach(() => { + resetMemoryDatastore(); + }); + + it('note create triggers memory extraction and stores memories', async () => { + const note = makeNote(); + await onNoteSavedToPalace(note, 'Work Notes', mockLog); + + const wings = await listWings(USER_A, PRODUCT); + expect(wings.length).toBe(1); + expect(wings[0].sourceWorkspaceId).toBe('ws-1'); + + const rooms = await listRooms(USER_A, PRODUCT, wings[0].id); + expect(rooms.length).toBeGreaterThanOrEqual(1); + + const memories = await listMemories(USER_A, PRODUCT, { wingId: wings[0].id }); + expect(memories.length).toBeGreaterThanOrEqual(1); + }); + + it('duplicate memories are skipped on re-save', async () => { + const note = makeNote(); + + await onNoteSavedToPalace(note, 'Work', mockLog); + const firstCount = (await listMemories(USER_A, PRODUCT, {})).length; + + // Save same note again — duplicates should be skipped + await onNoteSavedToPalace(note, 'Work', mockLog); + const secondCount = (await listMemories(USER_A, PRODUCT, {})).length; + + expect(secondCount).toBe(firstCount); + }); + + it('extraction failure does not throw', async () => { + const note = makeNote({ body: '' }); // empty body → short-circuit + // Should not throw + await expect(onNoteSavedToPalace(note, 'Work', mockLog)).resolves.toBeUndefined(); + }); + + it('wing auto-created from workspace on first save', async () => { + const note = makeNote({ workspaceId: 'ws-new' }); + await onNoteSavedToPalace(note, 'New Workspace', mockLog); + + const wings = await listWings(USER_A, PRODUCT); + expect(wings.length).toBe(1); + expect(wings[0].name).toBe('New Workspace'); + expect(wings[0].sourceWorkspaceId).toBe('ws-new'); + }); + + it('memories include sourceNoteId back-reference', async () => { + const note = makeNote({ id: 'note-ref-test' }); + await onNoteSavedToPalace(note, 'Work', mockLog); + + const memories = await listMemories(USER_A, PRODUCT, {}); + for (const mem of memories) { + expect(mem.sourceNoteId).toBe('note-ref-test'); + } + }); + + it('cross-user isolation: user A note save never creates memories for user B', async () => { + const noteA = makeNote({ userId: USER_A, workspaceId: 'ws-a' }); + const noteB = makeNote({ userId: USER_B, workspaceId: 'ws-b', id: 'note-b' }); + + await onNoteSavedToPalace(noteA, 'A Workspace', mockLog); + await onNoteSavedToPalace(noteB, 'B Workspace', mockLog); + + const memoriesA = await listMemories(USER_A, PRODUCT, {}); + const memoriesB = await listMemories(USER_B, PRODUCT, {}); + + // Each user only has their own memories + for (const m of memoriesA) expect(m.userId).toBe(USER_A); + for (const m of memoriesB) expect(m.userId).toBe(USER_B); + + // And they don't overlap + const wingsA = await listWings(USER_A, PRODUCT); + const wingsB = await listWings(USER_B, PRODUCT); + expect(wingsA.length).toBe(1); + expect(wingsB.length).toBe(1); + expect(wingsA[0].sourceWorkspaceId).toBe('ws-a'); + expect(wingsB[0].sourceWorkspaceId).toBe('ws-b'); + }); + + it('skips extraction when content is too short', async () => { + const note = makeNote({ body: 'Hi there' }); // < 50 chars after strip + await onNoteSavedToPalace(note, 'Work', mockLog); + + const memories = await listMemories(USER_A, PRODUCT, {}); + expect(memories.length).toBe(0); + }); +}); diff --git a/backend/src/modules/palace/palace-hooks.ts b/backend/src/modules/palace/palace-hooks.ts new file mode 100644 index 0000000..8cac9f4 --- /dev/null +++ b/backend/src/modules/palace/palace-hooks.ts @@ -0,0 +1,75 @@ +/** + * Palace auto-save hooks — fire-and-forget memory extraction on note save. + * + * Called from note-hooks.ts after note create/update. + * Gated behind PALACE_ENABLED + PALACE_EXTRACTION_ENABLED config. + * Failures are logged, never thrown — note save is never delayed. + */ + +import { config } from '../../lib/config.js'; +import { embedText, stripHtmlForEmbedding } from '../../lib/embeddings.js'; +import { trackEvent } from '../../lib/telemetry.js'; +import { ensureWing, ensureRoom, storeMemory, isNearDuplicate } from './repository.js'; +import { extractMemories } from './extractor.js'; +import type { NoteDoc } from '../notes/types.js'; +import type { FastifyBaseLogger } from 'fastify'; + +/** + * Extract and store memories from a note (fire-and-forget). + * + * Called after note create or update. Best-effort — failures are logged. + */ +export async function onNoteSavedToPalace( + note: NoteDoc, + workspaceName: string, + log: FastifyBaseLogger, +): Promise { + if (!config.PALACE_ENABLED) return; + if (!config.PALACE_EXTRACTION_ENABLED) return; + + const userId = note.userId; + const productId = note.productId; + + try { + const plainText = stripHtmlForEmbedding(note.body ?? ''); + if (plainText.length < 50) return; + + const wing = await ensureWing(userId, productId, note.workspaceId, workspaceName); + const memories = await extractMemories(note.body, note.title, workspaceName); + + if (memories.length === 0) return; + + let stored = 0; + let skippedDup = 0; + + for (const mem of memories) { + const room = await ensureRoom(userId, productId, wing.id, mem.roomSlug); + const embedding = await embedText(mem.content); + const isDup = await isNearDuplicate( + userId, productId, room.id, mem.hall, mem.content, embedding, + ); + + if (!isDup) { + await storeMemory( + userId, productId, wing.id, room.id, + mem.hall, mem.content, note.id, embedding, + ); + stored++; + } else { + skippedDup++; + } + } + + if (stored > 0) { + trackEvent('palace.memories_extracted', userId, { + noteId: note.id, + wingId: wing.id, + stored: String(stored), + skippedDup: String(skippedDup), + }); + log.info({ noteId: note.id, stored, skippedDup }, 'palace: memories extracted from note'); + } + } catch (err) { + log.warn({ noteId: note.id, err }, 'palace: memory extraction failed'); + } +}