feat(palace): auto-save hooks — extract memories on note create/update (N4)

This commit is contained in:
saravanakumardb1 2026-04-10 01:25:00 -07:00
parent d9d0cffc8d
commit 0af5f875bb
3 changed files with 207 additions and 0 deletions

View File

@ -11,6 +11,7 @@ import { llm } from './llm.js';
import { trackEvent } from './telemetry.js'; import { trackEvent } from './telemetry.js';
import type { NoteDoc } from '../modules/notes/types.js'; import type { NoteDoc } from '../modules/notes/types.js';
import { getCollection } from './datastore.js'; import { getCollection } from './datastore.js';
import { onNoteSavedToPalace } from '../modules/palace/palace-hooks.js';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
const MIN_WORDS_FOR_SUMMARY = 300; const MIN_WORDS_FOR_SUMMARY = 300;
@ -22,10 +23,12 @@ const MIN_WORDS_FOR_SUMMARY = 300;
export function runPostSaveHooks( export function runPostSaveHooks(
note: NoteDoc, note: NoteDoc,
log: FastifyBaseLogger, log: FastifyBaseLogger,
workspaceName?: string,
): void { ): void {
// Fire-and-forget — errors are logged, never thrown // Fire-and-forget — errors are logged, never thrown
void backgroundEmbed(note, log); void backgroundEmbed(note, log);
void backgroundAutoSummarize(note, log); void backgroundAutoSummarize(note, log);
void onNoteSavedToPalace(note, workspaceName ?? 'Untitled', log);
} }
/** /**

View File

@ -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> = {}): 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);
});
});

View File

@ -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<void> {
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');
}
}