diff --git a/backend/src/modules/palace/wakeup.test.ts b/backend/src/modules/palace/wakeup.test.ts new file mode 100644 index 0000000..1637c95 --- /dev/null +++ b/backend/src/modules/palace/wakeup.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetMemoryDatastore, TEST_USER_ID, TEST_PRODUCT_ID } from '../../test-helpers.js'; +import { ensureWing, ensureRoom, storeMemory, getWing } from './repository.js'; +import { buildNoteLettWakeUp, regenerateCriticalFacts } from './wakeup.js'; +import { estimateTokens } from '@bytelyst/palace'; + +const USER_A = TEST_USER_ID; +const USER_B = 'test-user-2'; +const PRODUCT = TEST_PRODUCT_ID; + +function makeEmbedding(seed: number): number[] { + const emb = new Array(8).fill(0); + emb[seed % 8] = 1.0; + return emb; +} + +describe('Palace Wake-Up Context (N2)', () => { + beforeEach(() => { + resetMemoryDatastore(); + }); + + it('wake-up context includes workspace identity (L0)', async () => { + const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Engineering Notes'); + await ensureRoom(USER_A, PRODUCT, wing.id, 'auth-migration'); + await ensureRoom(USER_A, PRODUCT, wing.id, 'api-design'); + + const ctx = await buildNoteLettWakeUp(USER_A, PRODUCT, wing.id); + expect(ctx.text).toContain('Engineering Notes'); + expect(ctx.text).toContain('auth-migration'); + expect(ctx.wingName).toBe('Engineering Notes'); + }); + + it('L1 facts reflect recent decisions and preferences', async () => { + const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work'); + const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth'); + + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Use JWT for all APIs'); + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'preferences', 'Prefer short-lived tokens'); + + const ctx = await buildNoteLettWakeUp(USER_A, PRODUCT, wing.id); + expect(ctx.text).toContain('JWT'); + expect(ctx.text).toContain('short-lived'); + }); + + it('L2 context is semantically relevant to task description', async () => { + const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work'); + const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth'); + + const emb = makeEmbedding(0); + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Auth uses JWT', undefined, emb); + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'events', 'Database migrated', undefined, makeEmbedding(4)); + + // Search with same embedding as JWT memory → should rank it first + const ctx = await buildNoteLettWakeUp(USER_A, PRODUCT, wing.id, 'auth implementation'); + // Can't guarantee semantic ranking without real embeddings, + // but the context should include both memories + expect(ctx.text.length).toBeGreaterThan(0); + }); + + it('L2 falls back to recent memories when no task description', async () => { + const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work'); + const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth'); + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'events', 'Sprint planning completed'); + + const ctx = await buildNoteLettWakeUp(USER_A, PRODUCT, wing.id); + expect(ctx.text).toContain('Sprint planning'); + }); + + it('total tokens stay within ~600 budget', async () => { + const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work'); + const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth'); + + // Store many memories to fill the budget + for (let i = 0; i < 20; i++) { + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', `Decision number ${i}: ${Array(50).fill('word').join(' ')}`); + } + + const ctx = await buildNoteLettWakeUp(USER_A, PRODUCT, wing.id); + const tokens = estimateTokens(ctx.text); + expect(tokens).toBeLessThanOrEqual(700); // Allow small overhead for labels + }); + + it('wake-up for user A never includes user B memories', async () => { + const wingA = await ensureWing(USER_A, PRODUCT, 'ws-a', 'A Workspace'); + const roomA = await ensureRoom(USER_A, PRODUCT, wingA.id, 'topic'); + await storeMemory(USER_A, PRODUCT, wingA.id, roomA.id, 'decisions', 'User A secret decision'); + + const wingB = await ensureWing(USER_B, PRODUCT, 'ws-b', 'B Workspace'); + const roomB = await ensureRoom(USER_B, PRODUCT, wingB.id, 'topic'); + await storeMemory(USER_B, PRODUCT, wingB.id, roomB.id, 'decisions', 'User B private info'); + + const ctxA = await buildNoteLettWakeUp(USER_A, PRODUCT, wingA.id); + const ctxB = await buildNoteLettWakeUp(USER_B, PRODUCT, wingB.id); + + expect(ctxA.text).toContain('User A secret'); + expect(ctxA.text).not.toContain('User B private'); + expect(ctxB.text).toContain('User B private'); + expect(ctxB.text).not.toContain('User A secret'); + }); + + it('empty palace returns minimal context gracefully', async () => { + const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Empty Workspace'); + + const ctx = await buildNoteLettWakeUp(USER_A, PRODUCT, wing.id); + expect(ctx.wingName).toBe('Empty Workspace'); + expect(ctx.text).toContain('Empty Workspace'); + // Should not throw + }); + + it('non-existent wing returns empty context', async () => { + const ctx = await buildNoteLettWakeUp(USER_A, PRODUCT, 'non-existent-wing'); + expect(ctx.text).toBe(''); + expect(ctx.totalChars).toBe(0); + }); + + it('L1 cache regeneration updates wing doc', async () => { + const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work'); + const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth'); + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Use Fastify 5'); + await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'preferences', 'Always validate with Zod'); + + const l1 = await regenerateCriticalFacts(USER_A, PRODUCT, wing.id); + expect(l1).toContain('Fastify'); + expect(l1).toContain('Zod'); + + // Check that wing doc has cached L1 + const updatedWing = await getWing(USER_A, PRODUCT, wing.id); + expect(updatedWing!.l1Cache).toContain('Fastify'); + expect(updatedWing!.l1CacheUpdatedAt).toBeTruthy(); + }); +}); diff --git a/backend/src/modules/palace/wakeup.ts b/backend/src/modules/palace/wakeup.ts new file mode 100644 index 0000000..f68a444 --- /dev/null +++ b/backend/src/modules/palace/wakeup.ts @@ -0,0 +1,175 @@ +/** + * Wake-up context builder for NoteLett palace. + * + * Builds L0/L1/L2 layered context within a configurable token budget. + * Uses @bytelyst/palace shared wakeup builder for assembly + truncation. + */ + +import { buildWakeUpLayers, WAKEUP_PRESETS, estimateTokens, truncateToTokenBudget } from '@bytelyst/palace'; +import type { WakeUpContext } from '@bytelyst/palace'; +import { config } from '../../lib/config.js'; +import { embedText, stripHtmlForEmbedding } from '../../lib/embeddings.js'; +import { getWing, listRooms, listMemories, searchSemantic, getWingSummary } from './repository.js'; +import type { PalaceMemoryDoc, PalaceWingDoc } from './types.js'; + +export type { WakeUpContext }; + +export interface NoteLettWakeUpContext extends WakeUpContext { + wingId: string; + wingName: string; +} + +const NOTELETT_PRESET = WAKEUP_PRESETS.notelett ?? { + totalBudget: 600, l0Budget: 50, l1Budget: 150, l2Budget: 400, +}; + +/** + * Build wake-up context for a workspace/wing. + * + * L0 — Identity: workspace name + room topics (~50 tokens) + * L1 — Critical Facts: recent decisions/preferences/insights (~150 tokens) + * L2 — Task Context: semantically relevant memories (~400 tokens) + */ +export async function buildNoteLettWakeUp( + userId: string, + productId: string, + wingId: string, + taskDescription?: string, +): Promise { + const wing = await getWing(userId, productId, wingId); + if (!wing) { + return { + text: '', + layers: [], + totalChars: 0, + truncated: false, + wingId, + wingName: 'Unknown', + }; + } + + const budget = { + ...NOTELETT_PRESET, + totalBudget: config.PALACE_WAKE_UP_BUDGET, + }; + + const l0 = await buildL0Identity(userId, productId, wing); + const l1 = await buildL1CriticalFacts(userId, productId, wingId, wing); + const l2 = await buildL2TaskContext(userId, productId, wingId, taskDescription); + + const ctx = buildWakeUpLayers(l0, l1, l2, budget); + + return { + ...ctx, + wingId, + wingName: wing.name, + }; +} + +/** + * L0 — Identity: workspace name, description, room topics. + */ +async function buildL0Identity( + userId: string, + productId: string, + wing: PalaceWingDoc, +): Promise { + const rooms = await listRooms(userId, productId, wing.id); + const roomNames = rooms.map(r => r.name).slice(0, 10).join(', '); + + const parts = [`Workspace: ${wing.name}`]; + if (wing.description) parts.push(wing.description); + if (roomNames) parts.push(`Topics: ${roomNames}`); + + return parts.join('\n'); +} + +/** + * L1 — Critical Facts: recent decisions, preferences, insights. + * Uses cached L1 if available, otherwise regenerates. + */ +async function buildL1CriticalFacts( + userId: string, + productId: string, + wingId: string, + wing: PalaceWingDoc, +): Promise { + // Try cached L1 + if (wing.l1Cache && wing.l1CacheUpdatedAt) { + const cacheAge = Date.now() - new Date(wing.l1CacheUpdatedAt).getTime(); + if (cacheAge < 24 * 3600_000) return wing.l1Cache; + } + + // Rebuild from recent decisions + preferences + insights + const factHalls = ['decisions', 'preferences', 'insights'] as const; + const recentFacts: PalaceMemoryDoc[] = []; + + for (const hall of factHalls) { + const mems = await listMemories(userId, productId, { + wingId, + hall, + limit: 5, + }); + recentFacts.push(...mems); + } + + if (recentFacts.length === 0) return ''; + + // Sort by recency + recentFacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + + const lines = recentFacts.slice(0, 8).map(m => `- [${m.hall}] ${m.content}`); + return lines.join('\n'); +} + +/** + * L2 — Task Context: semantically relevant memories (or recent if no task). + */ +async function buildL2TaskContext( + userId: string, + productId: string, + wingId: string, + taskDescription?: string, +): Promise { + let memories: PalaceMemoryDoc[]; + + if (taskDescription && taskDescription.trim().length > 0) { + const embedding = await embedText(taskDescription); + if (embedding) { + memories = await searchSemantic(userId, productId, taskDescription, embedding, wingId, 8); + } else { + memories = await listMemories(userId, productId, { wingId, limit: 8 }); + } + } else { + memories = await listMemories(userId, productId, { wingId, limit: 8 }); + } + + if (memories.length === 0) return ''; + + const lines = memories.map(m => `- [${m.hall}] ${m.content}`); + return lines.join('\n'); +} + +/** + * Regenerate L1 cache for a wing (called after memory extraction). + */ +export async function regenerateCriticalFacts( + userId: string, + productId: string, + wingId: string, +): Promise { + const wing = await getWing(userId, productId, wingId); + if (!wing) return ''; + + const l1 = await buildL1CriticalFacts(userId, productId, wingId, wing); + + // Update wing doc with cached L1 + const { getCollection } = await import('../../lib/datastore.js'); + const col = getCollection('palace_wings', '/userId'); + await col.update(wingId, userId, { + l1Cache: l1, + l1CacheUpdatedAt: new Date().toISOString(), + } as Partial); + + return l1; +}