feat(palace): wake-up context builder with L0/L1/L2 layers, L1 cache regeneration (N2)

This commit is contained in:
saravanakumardb1 2026-04-10 01:27:13 -07:00
parent 37fba5d4a5
commit a5dbeac77e
2 changed files with 306 additions and 0 deletions

View File

@ -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();
});
});

View File

@ -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<NoteLettWakeUpContext> {
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<string> {
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<string> {
// 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<string> {
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<string> {
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<PalaceWingDoc>('palace_wings', '/userId');
await col.update(wingId, userId, {
l1Cache: l1,
l1CacheUpdatedAt: new Date().toISOString(),
} as Partial<PalaceWingDoc>);
return l1;
}