feat(palace): wake-up context builder with L0/L1/L2 layers, L1 cache regeneration (N2)
This commit is contained in:
parent
37fba5d4a5
commit
a5dbeac77e
131
backend/src/modules/palace/wakeup.test.ts
Normal file
131
backend/src/modules/palace/wakeup.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
175
backend/src/modules/palace/wakeup.ts
Normal file
175
backend/src/modules/palace/wakeup.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user